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/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
+ ![Plot Output](https://billtompkins.github.io/densitty/docs/inline-histplot2d.png "example output image")
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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