densitty 0.8.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- densitty/__init__.py +0 -0
- densitty/ansi.py +109 -0
- densitty/ascii_art.py +24 -0
- densitty/axis.py +265 -0
- densitty/binning.py +240 -0
- densitty/detect.py +465 -0
- densitty/lineart.py +130 -0
- densitty/plot.py +201 -0
- densitty/truecolor.py +170 -0
- densitty/util.py +234 -0
- densitty-0.8.2.dist-info/METADATA +36 -0
- densitty-0.8.2.dist-info/RECORD +15 -0
- densitty-0.8.2.dist-info/WHEEL +5 -0
- densitty-0.8.2.dist-info/licenses/LICENSE +21 -0
- densitty-0.8.2.dist-info/top_level.txt +1 -0
densitty/plot.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Two-dimensional histogram (density plot) with textual output."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
from itertools import chain, zip_longest
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Callable, Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from . import ansi, axis, lineart
|
|
10
|
+
from .util import FloatLike
|
|
11
|
+
|
|
12
|
+
# pylint: disable=invalid-name
|
|
13
|
+
# User can set this to provide a default if os.terminal_size() fails:
|
|
14
|
+
default_terminal_size: Optional[os.terminal_size] = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclasses.dataclass
|
|
18
|
+
class Plot:
|
|
19
|
+
"""Create a textual 2-D density/histogram plot given binned data."""
|
|
20
|
+
|
|
21
|
+
# pylint: disable=too-many-instance-attributes
|
|
22
|
+
data: Sequence[Sequence[FloatLike]]
|
|
23
|
+
color_map: Callable = ansi.GRAYSCALE # Can return an ascii character or a color code
|
|
24
|
+
render_halfheight: bool = (
|
|
25
|
+
True # use fg/bg of "▄" to double Y resolution if color_map is non-ASCII
|
|
26
|
+
)
|
|
27
|
+
font_mapping: dict = dataclasses.field(default_factory=lambda: lineart.basic_font)
|
|
28
|
+
min_data: Optional[FloatLike] = None
|
|
29
|
+
max_data: Optional[FloatLike] = None
|
|
30
|
+
x_axis: Optional[axis.Axis] = None
|
|
31
|
+
y_axis: Optional[axis.Axis] = None
|
|
32
|
+
flip_y: bool = True # put the first row of data at the bottom of the output
|
|
33
|
+
|
|
34
|
+
def as_ascii(self):
|
|
35
|
+
"""Output using direct characters (ASCII-art)."""
|
|
36
|
+
data = self._normalize_data()
|
|
37
|
+
for line in data:
|
|
38
|
+
line_str = (self.color_map(x, None) for x in line)
|
|
39
|
+
yield "".join(line_str)
|
|
40
|
+
|
|
41
|
+
def as_color(self):
|
|
42
|
+
"""Output using ANSI color codes for background, with space character."""
|
|
43
|
+
data = self._normalize_data()
|
|
44
|
+
for line in data:
|
|
45
|
+
colors = (self.color_map(x, None) for x in line)
|
|
46
|
+
yield (" ".join(chain(colors, [ansi.RESET])))
|
|
47
|
+
|
|
48
|
+
def as_halfheight_color(self):
|
|
49
|
+
"""Output using ANSI color codes for foreground & background, with half-block character."""
|
|
50
|
+
data = self._normalize_data()
|
|
51
|
+
half_block = "▄" # Unicode U+2584 "Lower Half Block"
|
|
52
|
+
line_count = len(data)
|
|
53
|
+
lines = iter(data)
|
|
54
|
+
if line_count % 2:
|
|
55
|
+
# odd number of lines: special-case the first line:
|
|
56
|
+
line = next(lines, [])
|
|
57
|
+
colors = (self.color_map(None, x) for x in line)
|
|
58
|
+
yield (half_block.join(chain(colors, [ansi.RESET])))
|
|
59
|
+
for bg_line in lines:
|
|
60
|
+
fg_line = next(lines, [])
|
|
61
|
+
colors = (self.color_map(x, y) for x, y in zip(bg_line, fg_line))
|
|
62
|
+
yield (half_block.join(chain(colors, [ansi.RESET])))
|
|
63
|
+
|
|
64
|
+
def _normalize_data(self):
|
|
65
|
+
"""Normalize data to 0..1 interval based on min_data/max_data or actual min/max.
|
|
66
|
+
Also flips data if requested
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
min_data = min(min(line) for line in self.data) if self.min_data is None else self.min_data
|
|
70
|
+
max_data = max(max(line) for line in self.data) if self.max_data is None else self.max_data
|
|
71
|
+
data_scale = max_data - min_data
|
|
72
|
+
if data_scale == 0:
|
|
73
|
+
# all data has the same value (or we were told it does)
|
|
74
|
+
data_scale = sys.float_info.min
|
|
75
|
+
norm_data = tuple(tuple((x - min_data) / data_scale for x in line) for line in self.data)
|
|
76
|
+
|
|
77
|
+
return tuple(reversed(norm_data)) if self.flip_y else norm_data
|
|
78
|
+
|
|
79
|
+
def is_color(self):
|
|
80
|
+
"""Is color_map returning color codes, not ASCII-art?"""
|
|
81
|
+
return len(self.color_map(0.5, None)) > 1 # is color_map returning color codes?
|
|
82
|
+
|
|
83
|
+
def is_halfheight(self):
|
|
84
|
+
"""Are there two pixels per output character?"""
|
|
85
|
+
return self.is_color() and self.render_halfheight
|
|
86
|
+
|
|
87
|
+
def as_strings(self):
|
|
88
|
+
"""Scale data to 0..1 range and feed it through the appropriate output function"""
|
|
89
|
+
if self.is_halfheight():
|
|
90
|
+
plot_lines = tuple(self.as_halfheight_color())
|
|
91
|
+
elif self.is_color():
|
|
92
|
+
plot_lines = tuple(self.as_color())
|
|
93
|
+
else:
|
|
94
|
+
plot_lines = tuple(self.as_ascii())
|
|
95
|
+
|
|
96
|
+
num_rows = len(plot_lines)
|
|
97
|
+
num_cols = len(self.data[0])
|
|
98
|
+
|
|
99
|
+
if self.y_axis:
|
|
100
|
+
axis_lines = self.y_axis.render_as_y(num_rows, False, bool(self.x_axis), self.flip_y)
|
|
101
|
+
else:
|
|
102
|
+
axis_lines = ["" for _ in range(num_rows + bool(self.x_axis))]
|
|
103
|
+
|
|
104
|
+
left_margin = lineart.display_len(axis_lines[0])
|
|
105
|
+
if self.x_axis:
|
|
106
|
+
x_ticks, x_labels = self.x_axis.render_as_x(num_cols, left_margin)
|
|
107
|
+
axis_lines[-1] = lineart.merge_lines(x_ticks, axis_lines[-1])
|
|
108
|
+
axis_lines += [x_labels]
|
|
109
|
+
|
|
110
|
+
for frame, plot_line in zip_longest(axis_lines, plot_lines, fillvalue=""):
|
|
111
|
+
yield frame.translate(self.font_mapping) + plot_line
|
|
112
|
+
|
|
113
|
+
def show(self, printer=print):
|
|
114
|
+
"""Simple helper function to output/print a plot"""
|
|
115
|
+
for line in self.as_strings():
|
|
116
|
+
printer(line)
|
|
117
|
+
|
|
118
|
+
def _compute_scaling_multipliers(
|
|
119
|
+
self,
|
|
120
|
+
max_size: tuple[int, int],
|
|
121
|
+
keep_aspect_ratio: bool,
|
|
122
|
+
) -> tuple[float, float]:
|
|
123
|
+
"""Compute multpliers in X and Y (cols/rows) to resize data to fit in specified bounds."""
|
|
124
|
+
try:
|
|
125
|
+
terminal_size: Optional[os.terminal_size] = os.get_terminal_size()
|
|
126
|
+
except OSError:
|
|
127
|
+
terminal_size = default_terminal_size
|
|
128
|
+
|
|
129
|
+
if max_size[1] <= 0:
|
|
130
|
+
if terminal_size is None:
|
|
131
|
+
raise OSError("No terminal size from os.get_terminal_size()")
|
|
132
|
+
user_margin = -int(max_size[1]) if max_size[1] else 0
|
|
133
|
+
axis_margin = bool(self.x_axis) * 2 # 2 lines at bottom for X axis
|
|
134
|
+
y_mult = 2 if self.is_halfheight() else 1
|
|
135
|
+
max_size = (max_size[0], (terminal_size.lines - user_margin - axis_margin) * y_mult)
|
|
136
|
+
|
|
137
|
+
if max_size[0] <= 0:
|
|
138
|
+
if terminal_size is None:
|
|
139
|
+
raise OSError("No terminal size from os.get_terminal_size()")
|
|
140
|
+
user_margin = -int(max_size[0]) if max_size[0] else 0
|
|
141
|
+
if self.y_axis:
|
|
142
|
+
y_axis_lines = self.y_axis.render_as_y(
|
|
143
|
+
max_size[1], False, bool(self.x_axis), self.flip_y
|
|
144
|
+
)
|
|
145
|
+
# margin on left from Y axis width. 2-char buffer on right for X axis label:
|
|
146
|
+
axis_margin = len(y_axis_lines[0]) + 2 * bool(self.x_axis)
|
|
147
|
+
else:
|
|
148
|
+
axis_margin = 0
|
|
149
|
+
max_size = (terminal_size.columns - user_margin - axis_margin, max_size[1])
|
|
150
|
+
|
|
151
|
+
scaling = (max_size[0] / len(self.data[0]), max_size[1] / len(self.data))
|
|
152
|
+
if keep_aspect_ratio:
|
|
153
|
+
single_scale = min(scaling)
|
|
154
|
+
return single_scale, single_scale
|
|
155
|
+
return scaling
|
|
156
|
+
|
|
157
|
+
def upscale(
|
|
158
|
+
self,
|
|
159
|
+
max_size: tuple[int, int] = (0, 0),
|
|
160
|
+
max_expansion: tuple[int, int] = (3, 3),
|
|
161
|
+
keep_aspect_ratio: bool = False,
|
|
162
|
+
):
|
|
163
|
+
"""Scale up 'data' by repeating lines and values within lines.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
max_size : tuple (int, int)
|
|
168
|
+
If positive: Maximum number of columns, maximum number of rows
|
|
169
|
+
If zero: Use terminal size
|
|
170
|
+
If negative: Use as offset from terminal size
|
|
171
|
+
Default: Based on terminal size (0).
|
|
172
|
+
max_expansion : tuple (int, int)
|
|
173
|
+
maximum expansion factor in each direction. Default (3,3). 0=> No maximum
|
|
174
|
+
keep_aspect_ratio : bool
|
|
175
|
+
Require that X and Y scaling are equal.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
float_mult = self._compute_scaling_multipliers(max_size, keep_aspect_ratio)
|
|
179
|
+
col_mult = max(int(float_mult[0]), 1)
|
|
180
|
+
row_mult = max(int(float_mult[1]), 1)
|
|
181
|
+
|
|
182
|
+
if keep_aspect_ratio and any(max_expansion):
|
|
183
|
+
# Just in case user specifies keep_aspect_ratio but varying / missing maxima:
|
|
184
|
+
mult = max(int(m) for m in max_expansion)
|
|
185
|
+
max_expansion = (mult, mult)
|
|
186
|
+
|
|
187
|
+
if max_expansion[0] is not None:
|
|
188
|
+
col_mult = min(col_mult, max_expansion[0])
|
|
189
|
+
if max_expansion[1] is not None:
|
|
190
|
+
row_mult = min(row_mult, max_expansion[1])
|
|
191
|
+
|
|
192
|
+
def repeat_each(d, mult):
|
|
193
|
+
"""Repeat each element of 'd' 'mult' times"""
|
|
194
|
+
return sum(((x,) * mult for x in d), start=tuple())
|
|
195
|
+
|
|
196
|
+
# expand each of the lines using the column multiplier
|
|
197
|
+
x_expanded = (repeat_each(data_line, col_mult) for data_line in self.data)
|
|
198
|
+
|
|
199
|
+
# repeat each of those by the row multiplier
|
|
200
|
+
self.data = repeat_each(x_expanded, row_mult)
|
|
201
|
+
return self
|
densitty/truecolor.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""ANSI "True color" (24b, 16M colors) support."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from typing import Optional, Sequence
|
|
6
|
+
|
|
7
|
+
from . import ansi
|
|
8
|
+
from .util import clamp, clamp_rgb, interp, Vec
|
|
9
|
+
|
|
10
|
+
# Note: by default, we usethe widely supported 38;2;R;G;B to set foreground color
|
|
11
|
+
# An alternate spec is ODA which is 38:2::R:G:B (NB: colons rather than semicolons).
|
|
12
|
+
# The default (semicolons) is not nicely backwards-compatible, since the R;G;B appear
|
|
13
|
+
# to be control codes, and can e.g. turn on underline. But it is more widely supported.
|
|
14
|
+
|
|
15
|
+
# pylint: disable=invalid-name
|
|
16
|
+
# User can override this to use ODA codes if desired
|
|
17
|
+
use_oda_colorcodes = False
|
|
18
|
+
|
|
19
|
+
# Probably overkill: linear interpolation of RGB values gets muddy in the middle.
|
|
20
|
+
# Interpolating in CIE "L*a*b*" space typically gives much nicer results.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _rgb_to_linear_rgb(channel):
|
|
24
|
+
"""Gamma correction: Convert RGB to 'linear' RGB with gamma of 2.4."""
|
|
25
|
+
channel = channel / 255.0
|
|
26
|
+
if channel > 0.04045:
|
|
27
|
+
return math.pow((channel + 0.055) / 1.055, 2.4)
|
|
28
|
+
return channel / 12.92
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _linear_rgb_to_rgb(channel):
|
|
32
|
+
"""Inverse gamma correction: Convert 'linear' RGB back to RGB."""
|
|
33
|
+
if channel > 0.0031308:
|
|
34
|
+
return clamp(round(255 * 1.055 * math.pow(channel, 1.0 / 2.4) - 0.055), 0, 255)
|
|
35
|
+
return clamp(round(255 * 12.92 * channel), 0, 255)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _vector_transform(v, m):
|
|
39
|
+
"""Returns v * m, where v is a vector and m is a matrix (list of columns)."""
|
|
40
|
+
return [math.sumprod(v, col) for col in m]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rgb_to_lab(rgb: Vec) -> Vec:
|
|
44
|
+
"""Convert RGB triple to CIE LAB triple."""
|
|
45
|
+
|
|
46
|
+
linear_rgb = tuple(map(_rgb_to_linear_rgb, rgb))
|
|
47
|
+
# Conversion to XYZ that also includes white point calibration of [0.95047, 1.00000, 1.08883]
|
|
48
|
+
linear_rgb_to_xyzn = [
|
|
49
|
+
[0.43394994055572506, 0.376209769903311, 0.18984028954096394],
|
|
50
|
+
[0.2126729, 0.7151522, 0.072175],
|
|
51
|
+
[0.01775658275396527, 0.10946796102238184, 0.8727754562236529],
|
|
52
|
+
]
|
|
53
|
+
xyzn = _vector_transform(linear_rgb, linear_rgb_to_xyzn)
|
|
54
|
+
|
|
55
|
+
def f(t):
|
|
56
|
+
"""common part of xyz->lab transform"""
|
|
57
|
+
if t > 0.008856451679035631:
|
|
58
|
+
return math.pow(t, 1 / 3)
|
|
59
|
+
return 0.13793103448275862 + t / 0.12841854934601665
|
|
60
|
+
|
|
61
|
+
fxyz = tuple(map(f, xyzn))
|
|
62
|
+
|
|
63
|
+
lum = 116 * fxyz[1] - 16
|
|
64
|
+
a = 500 * (fxyz[0] - fxyz[1])
|
|
65
|
+
b = 200 * (fxyz[1] - fxyz[2])
|
|
66
|
+
return (lum, a, b)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _lab_to_rgb(lab: Vec) -> Vec:
|
|
70
|
+
"""Convert CIE LAB triple to RGB."""
|
|
71
|
+
|
|
72
|
+
fy = (lab[0] + 16) / 116
|
|
73
|
+
fx = lab[1] / 500 + fy
|
|
74
|
+
fz = fy - lab[2] / 200
|
|
75
|
+
|
|
76
|
+
def f_inv(t):
|
|
77
|
+
if t > 0.20689655172413793:
|
|
78
|
+
return t**3
|
|
79
|
+
return 0.12841854934601665 * (t - 0.13793103448275862)
|
|
80
|
+
|
|
81
|
+
xyzn = (f_inv(fx), f_inv(fy), f_inv(fz))
|
|
82
|
+
|
|
83
|
+
# Conversion from XYZ that also includes the white point calibration/normalization:
|
|
84
|
+
xyzn_to_linear_rgb = [
|
|
85
|
+
[3.079954503474, -1.5371385, -0.542815944262],
|
|
86
|
+
[-0.92125825502, 1.8760108, 0.04524741948],
|
|
87
|
+
[0.052887382398000005, -0.2040259, 1.151138514516],
|
|
88
|
+
]
|
|
89
|
+
linear_rgb = _vector_transform(xyzn, xyzn_to_linear_rgb)
|
|
90
|
+
|
|
91
|
+
rgb = tuple(map(_linear_rgb_to_rgb, linear_rgb))
|
|
92
|
+
return rgb
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def colormap_24b(color_points: Sequence[Vec], num_output_colors=256, interp_in_rgb=False):
|
|
96
|
+
"""Produce a function that returns ANSI colors interpolated from the provided sequence
|
|
97
|
+
Parameters
|
|
98
|
+
----------
|
|
99
|
+
color_points: Sequence[Vec]
|
|
100
|
+
Evenly-spaced color values corresponding to 0.0..1.0
|
|
101
|
+
num_output_colors: int
|
|
102
|
+
Number of distinct interpolated output colors to use
|
|
103
|
+
interp_in_rgb: bool
|
|
104
|
+
Interpolate in RGB space rather than Lab space
|
|
105
|
+
"""
|
|
106
|
+
# create the color map by interpolating between the given color points
|
|
107
|
+
if interp_in_rgb:
|
|
108
|
+
scale = tuple(
|
|
109
|
+
clamp_rgb(interp(color_points, x / (num_output_colors - 1)))
|
|
110
|
+
for x in range(num_output_colors)
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
lab_color_points = tuple(_rgb_to_lab(point) for point in color_points)
|
|
114
|
+
lab_scale = [
|
|
115
|
+
interp(lab_color_points, x / (num_output_colors - 1)) for x in range(num_output_colors)
|
|
116
|
+
]
|
|
117
|
+
scale = tuple(clamp_rgb(_lab_to_rgb(point)) for point in lab_scale)
|
|
118
|
+
|
|
119
|
+
def colorcode(bg_frac: Optional[float], fg_frac: Optional[float]):
|
|
120
|
+
codes = []
|
|
121
|
+
if fg_frac is not None:
|
|
122
|
+
fg_idx = clamp(round(fg_frac * num_output_colors), 0, num_output_colors - 1)
|
|
123
|
+
if use_oda_colorcodes:
|
|
124
|
+
codes += [f"38:2::{scale[fg_idx][0]}:{scale[fg_idx][1]}:{scale[fg_idx][2]}"]
|
|
125
|
+
else:
|
|
126
|
+
codes += [f"38;2;{scale[fg_idx][0]};{scale[fg_idx][1]};{scale[fg_idx][2]}"]
|
|
127
|
+
if bg_frac is not None:
|
|
128
|
+
bg_idx = clamp(round(bg_frac * num_output_colors), 0, num_output_colors - 1)
|
|
129
|
+
if use_oda_colorcodes:
|
|
130
|
+
codes += [f"48:2::{scale[bg_idx][0]}:{scale[bg_idx][1]}:{scale[bg_idx][2]}"]
|
|
131
|
+
else:
|
|
132
|
+
codes += [f"48;2;{scale[bg_idx][0]};{scale[bg_idx][1]};{scale[bg_idx][2]}"]
|
|
133
|
+
return ansi.compose(codes)
|
|
134
|
+
|
|
135
|
+
return colorcode
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# RGB Color triples to use in making color scales:
|
|
139
|
+
BLACK = (0, 0, 0)
|
|
140
|
+
WHITE = (255, 255, 255)
|
|
141
|
+
RED = (255, 0, 0)
|
|
142
|
+
GREEN = (0, 255, 0)
|
|
143
|
+
BLUE = (0, 0, 255)
|
|
144
|
+
YELLOW = (255, 255, 0)
|
|
145
|
+
ORANGE = (255, 128, 0)
|
|
146
|
+
CYAN = (0, 255, 255)
|
|
147
|
+
PURPLE = (102, 0, 102)
|
|
148
|
+
MAGENTA = (255, 0, 255)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# pylint: disable=invalid-name
|
|
152
|
+
# (0,0,0), (1,1,1), (2,2,2)...(255,255,255):
|
|
153
|
+
GRAYSCALE = colormap_24b([BLACK, WHITE], interp_in_rgb=True)
|
|
154
|
+
|
|
155
|
+
# More uniform gradation of lightness across the scale:
|
|
156
|
+
GRAYSCALE_LINEAR = colormap_24b([BLACK, WHITE], num_output_colors=512)
|
|
157
|
+
|
|
158
|
+
# Blue->Red
|
|
159
|
+
BLUE_RED = colormap_24b([BLUE, RED])
|
|
160
|
+
|
|
161
|
+
RAINBOW = colormap_24b([RED, ORANGE, YELLOW, GREEN, CYAN, BLUE, PURPLE])
|
|
162
|
+
|
|
163
|
+
REV_RAINBOW = colormap_24b([PURPLE, BLUE, CYAN, GREEN, YELLOW, ORANGE, RED])
|
|
164
|
+
|
|
165
|
+
# Starting from black, fade into reverse rainbow:
|
|
166
|
+
FADE_IN = colormap_24b([BLACK, PURPLE, BLUE, CYAN, GREEN, YELLOW, ORANGE, RED])
|
|
167
|
+
|
|
168
|
+
HOT = colormap_24b([BLACK, RED, ORANGE, YELLOW, WHITE])
|
|
169
|
+
|
|
170
|
+
COOL = colormap_24b([CYAN, MAGENTA])
|
densitty/util.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Utility functions."""
|
|
2
|
+
|
|
3
|
+
from bisect import bisect_left
|
|
4
|
+
from collections import namedtuple
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any, Protocol, Sequence, SupportsFloat
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FloatLike[T](SupportsFloat, Protocol):
|
|
11
|
+
"""A Protocol that supports the arithmetic ops we require, and can convert to float"""
|
|
12
|
+
|
|
13
|
+
def __lt__(self, __other: T) -> bool: ...
|
|
14
|
+
def __add__(self, __other: Any) -> T: ...
|
|
15
|
+
def __sub__(self, __other: Any) -> T: ...
|
|
16
|
+
def __mul__(self, __other: Any) -> T: ...
|
|
17
|
+
def __truediv__(self, __other: Any) -> T: ...
|
|
18
|
+
def __abs__(self) -> T: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ValueRange = namedtuple("ValueRange", ["min", "max"])
|
|
22
|
+
|
|
23
|
+
type Vec = Sequence[FloatLike]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def clamp(x, min_x, max_x):
|
|
27
|
+
"""Returns the value if within min/max range, else the range boundary."""
|
|
28
|
+
return max(min_x, min(max_x, x))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def clamp_rgb(rgb):
|
|
32
|
+
"""Returns closest valid RGB value"""
|
|
33
|
+
return tuple(clamp(round(x), 0, 255) for x in rgb)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def interp(piecewise: Sequence[Vec], x: float) -> Vec:
|
|
37
|
+
"""Evaluate a piecewise linear function, i.e. interpolate between the two closest values.
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
piecewise: Sequence[Vec]
|
|
41
|
+
Evenly spaced function values. piecewise[0] := f(0.0), piecewise[-1] := f(1.0)
|
|
42
|
+
x: float
|
|
43
|
+
value between 0.0 and 1.0
|
|
44
|
+
returns: Vec
|
|
45
|
+
f(x)
|
|
46
|
+
"""
|
|
47
|
+
max_idx = len(piecewise) - 1
|
|
48
|
+
float_idx = x * max_idx
|
|
49
|
+
lower_idx = math.floor(float_idx)
|
|
50
|
+
|
|
51
|
+
if lower_idx < 0:
|
|
52
|
+
return piecewise[0]
|
|
53
|
+
if lower_idx + 1 > max_idx:
|
|
54
|
+
return piecewise[-1]
|
|
55
|
+
frac = float_idx - lower_idx
|
|
56
|
+
lower_vec = piecewise[lower_idx]
|
|
57
|
+
upper_vec = piecewise[lower_idx + 1]
|
|
58
|
+
return tuple(lower * (1.0 - frac) + upper * frac for lower, upper in zip(lower_vec, upper_vec))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def nearest(stepwise: Sequence, x: float):
|
|
62
|
+
"""Given a list of function values, return the value closest to the specified point
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
stepwise: Sequence[Any]
|
|
66
|
+
Evenly spaced function values. piecewise[0] := f(0.0), piecewise[-1] := f(1.0)
|
|
67
|
+
x: float
|
|
68
|
+
value between 0.0 and 1.0
|
|
69
|
+
returns: Any
|
|
70
|
+
f(x') for x' closest to x in the original sequence
|
|
71
|
+
"""
|
|
72
|
+
max_idx = len(stepwise) - 1
|
|
73
|
+
idx = round(x * max_idx)
|
|
74
|
+
|
|
75
|
+
clamped_idx = clamp(idx, 0, max_idx)
|
|
76
|
+
return stepwise[clamped_idx]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decimal_value_range(v: ValueRange | Sequence):
|
|
80
|
+
"""Produce a ValueRange containing Decimal values"""
|
|
81
|
+
return ValueRange(Decimal(v[0]), Decimal(v[1]))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def sfrexp10(value):
|
|
85
|
+
"""Returns sign, base-10 fraction (mantissa), and exponent.
|
|
86
|
+
i.e. (s, f, e) such that value = s * f * 10 ** e with 0 <= f < 1.0
|
|
87
|
+
"""
|
|
88
|
+
if value == 0:
|
|
89
|
+
return 1, 0, -100
|
|
90
|
+
|
|
91
|
+
sign = -1 if value < 0 else 1
|
|
92
|
+
|
|
93
|
+
v = Decimal(abs(value))
|
|
94
|
+
exponent = v.adjusted() + 1
|
|
95
|
+
frac = v.scaleb(-exponent) # scale frac's exponent to be 0
|
|
96
|
+
|
|
97
|
+
return sign, frac, exponent
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
round_fractions = (
|
|
101
|
+
Decimal(1) / Decimal(10),
|
|
102
|
+
Decimal(1) / Decimal(8),
|
|
103
|
+
Decimal(1) / Decimal(6),
|
|
104
|
+
Decimal(1) / Decimal(5),
|
|
105
|
+
Decimal(1) / Decimal(4),
|
|
106
|
+
Decimal(1) / Decimal(3),
|
|
107
|
+
Decimal(2) / Decimal(5),
|
|
108
|
+
Decimal(1) / Decimal(2),
|
|
109
|
+
Decimal(2) / Decimal(3),
|
|
110
|
+
Decimal(4) / Decimal(5),
|
|
111
|
+
Decimal(1),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def round_up_ish(value, round_fracs=round_fractions):
|
|
116
|
+
"""'Round' the value up to the next highest value in 'round_vals' times a multiple of 10
|
|
117
|
+
|
|
118
|
+
Parameters
|
|
119
|
+
----------
|
|
120
|
+
value: input value
|
|
121
|
+
round_vals: the allowable values (mantissa in base 10)
|
|
122
|
+
return: the closest round_vals[i] * 10**N equal to or larger than 'value'
|
|
123
|
+
"""
|
|
124
|
+
sign, frac, exp = sfrexp10(value)
|
|
125
|
+
|
|
126
|
+
# if we're passed in a float that can't be represented in binary (say 0.1 or 0.2), it will be
|
|
127
|
+
# rounded up to the next representable float. Subtract the smallest possible value (ulp) to
|
|
128
|
+
# so that when we round up, it can match an exact Decimal("0.1") or such:
|
|
129
|
+
frac -= Decimal(math.ulp(frac))
|
|
130
|
+
|
|
131
|
+
idx = bisect_left(round_fracs, frac) # find index that this would be inserted before (>= frac)
|
|
132
|
+
round_frac = round_fracs[idx]
|
|
133
|
+
|
|
134
|
+
return sign * round_frac.scaleb(exp)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def roundness(value):
|
|
138
|
+
"""Metric for how 'round' a value is. 10 is rounder than 1, is rounder than 1.1."""
|
|
139
|
+
|
|
140
|
+
# if value is a sequence, combine the roundness of all elements, prioritizing in order:
|
|
141
|
+
if isinstance(value, Sequence):
|
|
142
|
+
out, weight = 0, 1
|
|
143
|
+
for v in value:
|
|
144
|
+
out += roundness(v) * weight
|
|
145
|
+
weight *= 0.99
|
|
146
|
+
return out
|
|
147
|
+
|
|
148
|
+
if value == 0:
|
|
149
|
+
# 0 is the roundest value
|
|
150
|
+
return 1000 # equivalent to roundness of 1e1000
|
|
151
|
+
_, frac, exp = sfrexp10(value)
|
|
152
|
+
|
|
153
|
+
round_frac = round(frac, 5) # round to specific # of digits so we can interpret as fraction
|
|
154
|
+
penalties = {
|
|
155
|
+
1.00000: 0.0, # no penalty for multiples of 10
|
|
156
|
+
0.50000: 0.5, # penalty for multiple of 5 vs multiple of 10
|
|
157
|
+
0.25000: 0.6, # penalty for multiple of 4 vs multiple of 10
|
|
158
|
+
0.75000: 0.6, #
|
|
159
|
+
Decimal("0.33333"): 0.7, # penalty for multiple of 3 vs multiple of 10
|
|
160
|
+
Decimal("0.66667"): 0.7, #
|
|
161
|
+
0.12500: 0.8, # penalty for multiple of 8 vs multiple of 10
|
|
162
|
+
0.37500: 0.8,
|
|
163
|
+
0.62500: 0.8,
|
|
164
|
+
0.87500: 0.8,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if round_frac in penalties:
|
|
168
|
+
return exp - penalties[round_frac]
|
|
169
|
+
|
|
170
|
+
# Ouch: our fractional part is just not nice, so maximally un-round:
|
|
171
|
+
return -1000 # equivalent to roundness of 1e-1000
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def most_round(values):
|
|
175
|
+
"""Pick the most round of the input values. Ties go to the earliest."""
|
|
176
|
+
best_r = -1e100
|
|
177
|
+
best_v = 0
|
|
178
|
+
for v in values:
|
|
179
|
+
r = roundness(v)
|
|
180
|
+
if r > best_r:
|
|
181
|
+
best_r, best_v = r, v
|
|
182
|
+
return best_v
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def pick_step_size(value_range, num_steps_hint, min_steps_per_label=1) -> tuple[Decimal, Decimal]:
|
|
186
|
+
"""Try to pick a step size that gives nice round values for step positions.
|
|
187
|
+
For coming up with nice tick positions for an axis, and with nice bin sizes for binning.
|
|
188
|
+
For an axis, it is also useful to produce an interval between labeled ticks.
|
|
189
|
+
|
|
190
|
+
Parameters
|
|
191
|
+
----------
|
|
192
|
+
value_range: bounds of interval
|
|
193
|
+
num_steps_hint: approximate number of steps desired for the interval
|
|
194
|
+
min_steps_per_label: for use with axis/label generation, as labels take more space than ticks
|
|
195
|
+
return: step size, interval between labeled steps/ticks
|
|
196
|
+
"""
|
|
197
|
+
num_steps_hint = max(1, num_steps_hint)
|
|
198
|
+
# if steps are 0,1,2,3,4,5,6... or 0,2,4,6,8,10,... steps_per_label of 5 is sensible,
|
|
199
|
+
# if steps are 0,5,10,15,20,... steps_per_label of 4 is sensible
|
|
200
|
+
nominal_step = (value_range.max - value_range.min) / num_steps_hint
|
|
201
|
+
|
|
202
|
+
# Figure out the order-of-magnitude (power of 10), aka "decade" of the steps:
|
|
203
|
+
log_nominal = math.log10(nominal_step)
|
|
204
|
+
log_decade = math.floor(log_nominal) # i.e. # of digits
|
|
205
|
+
decade = Decimal(10) ** log_decade
|
|
206
|
+
|
|
207
|
+
# Now figure out where in that decade we are, so we can pick the closest 1/2/5 value
|
|
208
|
+
log_frac = log_nominal - log_decade # remainder after decade taken out
|
|
209
|
+
frac = 10**log_frac # i.e. fraction through the decade (shift decimal point to front)
|
|
210
|
+
|
|
211
|
+
# common-case: label every or every-other, or every 5th, or every 10th
|
|
212
|
+
if min_steps_per_label <= 2:
|
|
213
|
+
steps_per_label = min_steps_per_label
|
|
214
|
+
elif min_steps_per_label <= 5:
|
|
215
|
+
steps_per_label = 5
|
|
216
|
+
else:
|
|
217
|
+
steps_per_label = max(min_steps_per_label, 10)
|
|
218
|
+
|
|
219
|
+
if frac < 1.1:
|
|
220
|
+
step = decade
|
|
221
|
+
elif frac < 2.2:
|
|
222
|
+
step = 2 * decade
|
|
223
|
+
# Steps of .2, don't label every other one
|
|
224
|
+
if steps_per_label == 2:
|
|
225
|
+
steps_per_label = 5
|
|
226
|
+
elif frac < 5.5:
|
|
227
|
+
step = 5 * decade
|
|
228
|
+
# ticks every .5, don't label every 5th
|
|
229
|
+
if steps_per_label == 5:
|
|
230
|
+
steps_per_label = max(round(min_steps_per_label / 2) * 2, 6)
|
|
231
|
+
else:
|
|
232
|
+
step = 10 * decade
|
|
233
|
+
|
|
234
|
+
return step, step * steps_per_label
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: densitty
|
|
3
|
+
Version: 0.8.2
|
|
4
|
+
Summary: densitty - create textual 2-D density plots, heatmaps, and 2-D histograms in Python
|
|
5
|
+
Author: Bill Tompkins
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/BillTompkins/densitty
|
|
8
|
+
Keywords: densitty,ascii,ascii-art,plotting,terminal,Python
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Programming Language :: Python :: Free Threading :: 3 - Stable
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
<h1 align="center">densitty</h1>
|
|
24
|
+
<h2 align="center"> Terminal-based 2-D Histogram, Density Plots, and Heatmaps</h2>
|
|
25
|
+
|
|
26
|
+
Generate 2-D histograms (density plots, heat maps, eye diagrams) similar to [matplotlib's hist2d](https://matplotlib.org/stable/gallery/statistics/hist.html "hist2d"), but with output in the terminal, and no external dependencies.
|
|
27
|
+
|
|
28
|
+

|
|
29
|
+
|
|
30
|
+
## [Examples/Gallery](https://billtompkins.github.io/densitty/docs/examples.html)
|
|
31
|
+
|
|
32
|
+
## [Sub-modules / Usage Notes](https://billtompkins.github.io/densitty/docs/usage.html)
|
|
33
|
+
|
|
34
|
+
## [Color, Size, and Glyph Support](https://billtompkins.github.io/densitty/docs/terminal_support.html)
|
|
35
|
+
|
|
36
|
+
## API (TODO)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
densitty/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
densitty/ansi.py,sha256=YHagUsCwbyNlOkV2pXJuxolWwjvs1E4hxMnJaohqTfE,3661
|
|
3
|
+
densitty/ascii_art.py,sha256=-MUppQkEeCAqSxdLCfuDRD1NKtzSrJAt2WdJEL56_fI,589
|
|
4
|
+
densitty/axis.py,sha256=XANbgvOltS0XhUvUB9dfUBvybEfOmcjI5xGc2NZDgak,10120
|
|
5
|
+
densitty/binning.py,sha256=LioKw7A0xK_lzUEY2tINiL4fySxVNllxrgh2bD4T9AA,9549
|
|
6
|
+
densitty/detect.py,sha256=LBRUDHxpqLq3p0zRUNS7-3YCBSyclfM0vVy0ZmzSVNs,17725
|
|
7
|
+
densitty/lineart.py,sha256=Qkw_F1QEK9yd0ttNjg62bomIavMPza3pk8w7DA85zWs,4124
|
|
8
|
+
densitty/plot.py,sha256=dxdPRensTxWd9CcD2SbekqAe0iOMcKSKR24wrNc5aCw,8340
|
|
9
|
+
densitty/truecolor.py,sha256=uSrT4Qm0T0ZFwXxm1oyNTrdNCDO7ZfUTI687bX5r-8I,5990
|
|
10
|
+
densitty/util.py,sha256=U0B5RSxQOdq5M6no3OC_buZizAcVjTPwwqKvk4QJ5Rc,7888
|
|
11
|
+
densitty-0.8.2.dist-info/licenses/LICENSE,sha256=LexlQlxS7F07WxcVOOmZAZ_3vReYv0cM8Zg6pTx7_fI,1073
|
|
12
|
+
densitty-0.8.2.dist-info/METADATA,sha256=QK1lEOpas0eZ86enDAObh3bxuS1GoQ_Gmk5X2c6lgpg,1646
|
|
13
|
+
densitty-0.8.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
densitty-0.8.2.dist-info/top_level.txt,sha256=Q50fHzFeZkhO_61VVAIJZyKU44Upacx_blojlLpYqNo,9
|
|
15
|
+
densitty-0.8.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 William Tompkins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
densitty
|