densitty 0.8.2__py3-none-any.whl → 1.0.0__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 CHANGED
@@ -0,0 +1,9 @@
1
+ """In-terminal 2-D histogram/density/heatmap plotting"""
2
+
3
+ from .detect import densityplot2d, grid_heatmap, histplot2d, plot
4
+ from .detect import GRAYSCALE, FADE_IN, RAINBOW, REV_RAINBOW
5
+ from .axis import Axis
6
+ from .plotting import Plot
7
+ from .colorbar import make_colorbar
8
+ from .binning import histogram2d
9
+ from .smoothing import smooth2d
densitty/ansi.py CHANGED
@@ -8,7 +8,6 @@ from typing import Optional, Sequence
8
8
 
9
9
  from .util import nearest
10
10
 
11
-
12
11
  RESET = "\033[0m"
13
12
 
14
13
 
densitty/axis.py CHANGED
@@ -4,15 +4,10 @@ import dataclasses
4
4
  from decimal import Decimal
5
5
  import itertools
6
6
  import math
7
- from typing import Optional
7
+ from typing import Optional, Sequence
8
8
 
9
- from . import lineart
10
- from .util import FloatLike, ValueRange, pick_step_size
11
-
12
- MIN_X_TICKS_PER_LABEL = 4
13
- MIN_Y_TICKS_PER_LABEL = 2
14
- DEFAULT_X_COLS_PER_TICK = 4
15
- DEFAULT_Y_ROWS_PER_TICK = 2
9
+ from . import lineart, util
10
+ from .util import FloatLike, ValueRange
16
11
 
17
12
 
18
13
  @dataclasses.dataclass
@@ -31,16 +26,63 @@ x_border = {False: BorderChars(" ", " ", " "), True: BorderChars("╶", "─", "
31
26
  # Helper functions used by the Axis class below
32
27
 
33
28
 
34
- def add_label(line: list[str], label: str, ctr_pos: int):
29
+ def pick_step_size(lower_bound) -> Decimal:
30
+ """Try to pick a step size that gives nice round values for step positions."""
31
+ if lower_bound <= 0:
32
+ raise ValueError("pick_step_size called with 0 or negative value")
33
+
34
+ _, frac, exp = util.sfrexp10(lower_bound) # 0.1 <= frac < 1.0
35
+
36
+ # round up to an appropriate "round" value that starts with 1/2/5
37
+ if frac <= Decimal("0.2"):
38
+ out = Decimal((0, (2,), exp - 1))
39
+ elif frac <= Decimal("0.5"):
40
+ out = Decimal((0, (5,), exp - 1))
41
+ else:
42
+ out = Decimal((0, (1,), exp))
43
+
44
+ if out >= 10:
45
+ # this will be printed as 1E1 or such. Give it more significant figures so
46
+ # it is printed as an integer:
47
+ return out.quantize(1)
48
+ return out
49
+
50
+
51
+ def add_x_label(line: list[str], label: str, ctr_pos: FloatLike):
35
52
  """Adds the label string to the output line, centered at specified position
36
53
  The output line is a list of single-character strings, to make this kind of thing
37
54
  straightforward"""
38
55
  width = len(label)
39
- start_col = max(ctr_pos - width // 2, 0)
56
+ start_col = int(max(float(ctr_pos) + 0.49 - width / 2, 0))
40
57
  end_col = start_col + width
41
58
  line[start_col:end_col] = list(label)
42
59
 
43
60
 
61
+ def add_x_tick(line: list[str], start_pos: float, left_margin: int, num_cols: int):
62
+ """Adds a (possibly fractional) tick to the output line, centered at specified position"""
63
+ base_idx = int(start_pos)
64
+ fractional_pos = start_pos - base_idx
65
+ margined_idx = base_idx + left_margin
66
+ if 0.25 <= fractional_pos <= 0.75:
67
+ # add a tick in the center of the appropriate bin
68
+ line[margined_idx] = lineart.merge_chars("│", line[margined_idx])
69
+ return
70
+ if fractional_pos < 0.25:
71
+ left_idx = max(margined_idx - 1, 0)
72
+ else:
73
+ left_idx = margined_idx
74
+
75
+ if base_idx == 0:
76
+ line[left_idx] = lineart.merge_chars("│", line[left_idx])
77
+ else:
78
+ line[left_idx] = "╱"
79
+
80
+ if base_idx >= num_cols - 1:
81
+ line[left_idx + 1] = lineart.merge_chars("│", line[left_idx + 1])
82
+ else:
83
+ line[left_idx + 1] = "╲"
84
+
85
+
44
86
  def gen_tick_values(value_range, tick_step):
45
87
  """Produce tick values in the specified range. Basically numpy.arange"""
46
88
 
@@ -50,29 +92,142 @@ def gen_tick_values(value_range, tick_step):
50
92
  tick += tick_step
51
93
 
52
94
 
53
- def gen_labels(
54
- value_range: ValueRange, num_ticks, min_ticks_per_label, fmt, label_end_ticks=False
95
+ def positions_to_labels(
96
+ printed_positions: Sequence[Decimal], ticked_positions: Sequence[FloatLike], fmt: str
97
+ ) -> dict[FloatLike, str]:
98
+ """Given positions, construct a label dict mapping position to string to be printed there.
99
+ The first positions get a printed value, the second set get a blank, for a bare 'tick'"""
100
+
101
+ tick_dict = {p: "" for p in ticked_positions}
102
+ printed_dict = {p: fmt.format(p) for p in util.sanitize_decimals(printed_positions)}
103
+ return tick_dict | printed_dict
104
+
105
+
106
+ def calc_min_step(
107
+ value_range: ValueRange,
108
+ bin_width: FloatLike,
109
+ accomodate_values: bool,
110
+ tick_space: int,
111
+ fmt: str,
55
112
  ):
113
+ """Calculate minimum step size for tick placement.
114
+
115
+ When accomodate_values is True, considers the width of printed labels.
116
+ Otherwise, only considers tick spacing requirements.
117
+ """
118
+ if accomodate_values:
119
+ # find a representative printed label width to use for basic calculations
120
+ test_tick_step = pick_step_size((value_range.max - value_range.min) / 5)
121
+ test_values_printed = tuple(
122
+ fmt.format(value) for value in gen_tick_values(value_range, test_tick_step)
123
+ )
124
+ widths_in_bins = tuple(len(p) for p in test_values_printed)
125
+ # get the 3'd lowest width, or highest if there are less than 3 due to tick-step roundup:
126
+ example_width = sorted(widths_in_bins)[:3][-1]
127
+ min_printed_step = (example_width + 1) * bin_width
128
+ # If the printed labels are small (single-digit), the ticks themselves might be the
129
+ # limiting factor, especially if they are X-axis fractional ticks like "/\"
130
+ return max(min_printed_step, bin_width * (2 + tick_space * 2))
131
+ return bin_width * (2 + tick_space)
132
+
133
+
134
+ def gen_position_subsets(positions: tuple, tick_step: Decimal) -> list[Sequence[Decimal]]:
135
+ """Generate candidate label position subsets based on tick step digit.
136
+
137
+ For tick steps starting with 1 or 2: generates every-5th subsets (5 variants).
138
+ For tick steps starting with 5: generates every-2nd subsets (2 variants).
139
+ """
140
+ step_digit = tick_step.as_tuple().digits[0] # leading digit of tick step: 1, 2, or 5
141
+ # we want to pick different position subsets depending on whether we're advancing by
142
+ # 1eX, 2eX, or 5eX:
143
+ position_subsets = []
144
+ if step_digit in (1, 2):
145
+ # print on every fifth one, starting at 0..4
146
+ position_subsets += list(util.sanitize_decimals(positions[start::5]) for start in range(5))
147
+ elif step_digit == 5:
148
+ # print on every second one, starting at 0 or 1
149
+ position_subsets += list(util.sanitize_decimals(positions[start::2]) for start in range(2))
150
+ return position_subsets
151
+
152
+
153
+ def label_ends_only(positions, tick_step, bin_width, accomodate_values, fmt):
154
+ """See if printing just the labels for the first and last ticks will fit"""
155
+
156
+ if not accomodate_values:
157
+ # For Y axis / we don't care about printed widths
158
+ return positions_to_labels((positions[0], positions[-1]), positions, fmt)
159
+
160
+ end_positions = (positions[0], positions[-1])
161
+ ends_printed = tuple(positions_to_labels(end_positions, [], fmt).values())
162
+
163
+ half_len_first = len(ends_printed[0]) // 2
164
+ half_len_last = (len(ends_printed[1]) + 1) // 2
165
+ space_available = math.floor(tick_step / bin_width)
166
+
167
+ if half_len_first + half_len_last <= space_available:
168
+ # We can fit values on the first and last ticks
169
+ return positions_to_labels(end_positions, positions, fmt)
170
+
171
+ # Not enough space to print two values, so just print the first
172
+ return positions_to_labels(end_positions[:1], positions, fmt)
173
+
174
+
175
+ def find_fitting_subset(position_subsets, ticks_per_bin, accomodate_values, fmt):
176
+ """Find roundest label subset that fits within space constraints"""
177
+ for position_subset in util.roundness_ordered(position_subsets):
178
+ if not accomodate_values: # it doesn't matter what the printed widths are, it will fit
179
+ return position_subset
180
+ # We're printing at most one value for every 2 ticks, so just make sure
181
+ # that the printed values will not run over the adjacent ticks' area
182
+ # Given the initial min_step logic, this will likely always be true
183
+ printed_widths = (
184
+ len(label) for label in positions_to_labels(position_subset, [], fmt).values()
185
+ )
186
+ allowed_width = ticks_per_bin * 2 - 2
187
+ if max(printed_widths) <= allowed_width:
188
+ return position_subset
189
+ return tuple()
190
+
191
+
192
+ def gen_full_labels(value_range: ValueRange, num_bins, accomodate_values, tick_space, fmt):
56
193
  """Generate positions for labels (plain ticks & ticks with value)"""
57
- tick_step, label_step = pick_step_size(value_range, num_ticks, min_ticks_per_label)
194
+ bin_width = (value_range.max - value_range.min) / num_bins
195
+
196
+ if bin_width <= 0:
197
+ # we don't have a sensible range for the axis values, so just have empty ticks
198
+ return {}
199
+
200
+ min_step = calc_min_step(value_range, bin_width, accomodate_values, tick_space, fmt)
201
+ cur_tick_step = pick_step_size(min_step)
202
+
203
+ while True:
204
+ positions = tuple(gen_tick_values(value_range, cur_tick_step))
205
+
206
+ if len(positions) == 0:
207
+ # No suitable ticks/labels.
208
+ if accomodate_values:
209
+ # Printed value labels won't fit, try without them
210
+ return gen_full_labels(value_range, num_bins, False, tick_space, "")
211
+ # Nothing fits
212
+ return {}
213
+ if len(positions) == 1:
214
+ return positions_to_labels(positions, [], fmt)
58
215
 
59
- ticks = list(gen_tick_values(value_range, tick_step))
60
- label_values = list(gen_tick_values(value_range, label_step))
61
- if label_end_ticks or len(label_values) <= 2:
62
- # ensure that first & last ticks have labels:
63
- if label_values[0] != ticks[0]:
64
- label_values = ticks[:1] + label_values
65
- if label_values[-1] != ticks[-1]:
66
- label_values += ticks[-1:]
216
+ position_subsets = gen_position_subsets(positions, cur_tick_step)
67
217
 
68
- # sanity: if all but one ticks have labels, just label them all
69
- if len(label_values) >= len(ticks) - 1:
70
- label_values = ticks
218
+ # Check to see if all generated label subsets only have a single entry
219
+ if max(len(subset) for subset in position_subsets) == 1:
220
+ # Try to just label the ends:
221
+ return label_ends_only(positions, cur_tick_step, bin_width, accomodate_values, fmt)
71
222
 
72
- ticks_only = {value: "" for value in ticks}
73
- labeled_ticks = {value: fmt.format(value) for value in label_values}
223
+ best_subset = find_fitting_subset(
224
+ position_subsets, cur_tick_step / bin_width, accomodate_values, fmt
225
+ )
226
+
227
+ if best_subset:
228
+ return positions_to_labels(best_subset, positions, fmt)
74
229
 
75
- return ticks_only | labeled_ticks
230
+ cur_tick_step = pick_step_size(float(cur_tick_step) * 1.01)
76
231
 
77
232
 
78
233
  def calc_edges(value_range, num_bins, values_are_edges):
@@ -88,7 +243,7 @@ def calc_edges(value_range, num_bins, values_are_edges):
88
243
  values_are_edges: bool
89
244
  Indicates that value_range specifies outside edges rather than bin centers
90
245
  """
91
- if values_are_edges:
246
+ if values_are_edges or num_bins == 1:
92
247
  bin_delta = (value_range.max - value_range.min) / num_bins
93
248
  first_bin_min = value_range.min
94
249
  else:
@@ -107,7 +262,9 @@ class Axis:
107
262
  """Options for axis generation."""
108
263
 
109
264
  value_range: ValueRange # can also specify as a tuple of (min, max)
110
- labels: Optional[dict[float, str]] = None # map axis value to label (plus tick) at that value
265
+ labels: Optional[dict[FloatLike, str]] = (
266
+ None # map axis value to label (plus tick) at that value
267
+ )
111
268
  label_fmt: str = "{}" # format for generated labels
112
269
  border_line: bool = False # embed ticks in a horizontal X-axis or vertical Y-axis line
113
270
  values_are_edges: bool = False # N+1 values, indicating boundaries between pixels, not centers
@@ -116,7 +273,7 @@ class Axis:
116
273
  def __init__(
117
274
  self,
118
275
  value_range: ValueRange | tuple[FloatLike, FloatLike],
119
- labels: Optional[dict[float, str]] = None,
276
+ labels: Optional[dict[FloatLike, str]] = None,
120
277
  label_fmt: str = "{}",
121
278
  border_line: bool = False,
122
279
  values_are_edges: bool = False,
@@ -137,10 +294,11 @@ class Axis:
137
294
  def _unjustified_y_axis(self, num_rows: int):
138
295
  """Returns the Y axis string for each line of the plot"""
139
296
  if self.labels is None:
140
- labels = gen_labels(
297
+ labels = gen_full_labels(
141
298
  self.value_range,
142
- num_rows // DEFAULT_Y_ROWS_PER_TICK,
143
- MIN_Y_TICKS_PER_LABEL,
299
+ num_rows,
300
+ False,
301
+ 0,
144
302
  self.label_fmt,
145
303
  )
146
304
  else:
@@ -154,10 +312,12 @@ class Axis:
154
312
  if label_values and row_min <= label_values[0] <= row_max:
155
313
  label_str = labels[label_values[0]]
156
314
 
157
- offset_frac = (label_values[0] - row_min) / (row_max - row_min)
158
- if offset_frac < 0.25 and self.fractional_tick_pos:
315
+ offset_frac = (float(label_values[0]) - float(row_min)) / float(row_max - row_min)
316
+ # Try to avoid our cutoffs being exactly where roundoff errors can pop up and
317
+ # cause inconsistent behavior. So 0.249 rather than 0.25, 0.751 rather than 0.75:
318
+ if offset_frac < 0.249 and self.fractional_tick_pos:
159
319
  tick_char = "▔"
160
- elif offset_frac > 0.75 and self.fractional_tick_pos:
320
+ elif offset_frac > 0.751 and self.fractional_tick_pos:
161
321
  tick_char = "▁"
162
322
  else:
163
323
  tick_char = "─"
@@ -215,14 +375,9 @@ class Axis:
215
375
  left_margin: int
216
376
  chars to the left of leftmost data col. May have Labels/border-line.
217
377
  """
218
-
219
378
  if self.labels is None:
220
- labels = gen_labels(
221
- self.value_range,
222
- num_cols // DEFAULT_X_COLS_PER_TICK,
223
- MIN_X_TICKS_PER_LABEL,
224
- self.label_fmt,
225
- )
379
+ tick_space = 1 if self.fractional_tick_pos else 0
380
+ labels = gen_full_labels(self.value_range, num_cols, True, tick_space, self.label_fmt)
226
381
  else:
227
382
  labels = self.labels
228
383
 
@@ -242,24 +397,32 @@ class Axis:
242
397
  for col_idx, (col_min, col_max) in enumerate(bins):
243
398
  # use Decimal.next_plus to accomodate rounding error/truncation
244
399
  if label_values and col_min <= label_values[0] <= col_max.next_plus():
245
- add_label(label_line, labels[label_values[0]], col_idx + left_margin)
246
- tick_idx = left_margin + col_idx
247
- offset_frac = (label_values[0] - col_min) / (col_max - col_min)
248
- if self.fractional_tick_pos and offset_frac < 0.25:
249
- if col_idx == 0:
250
- tick_line[tick_idx - 1] = lineart.merge_chars("│", tick_line[tick_idx - 1])
251
- else:
252
- tick_line[tick_idx - 1] = "╱"
253
- tick_line[tick_idx] = "╲"
254
- elif self.fractional_tick_pos and offset_frac > 0.75:
255
- tick_line[tick_idx] = "╱"
256
- if col_idx < num_cols - 1:
257
- tick_line[tick_idx + 1] = "╲"
258
- else:
259
- tick_line[tick_idx + 1] = lineart.merge_chars("│", tick_line[tick_idx + 1])
400
+ if self.fractional_tick_pos:
401
+ offset_frac = (label_values[0] - col_min) / (col_max - col_min)
260
402
  else:
261
- tick_line[tick_idx] = lineart.merge_chars("│", tick_line[tick_idx])
403
+ offset_frac = 0.5 # not doing fractional tick positioning == center the tick
404
+
405
+ add_x_tick(tick_line, col_idx + offset_frac, left_margin, num_cols)
406
+ add_x_label(
407
+ label_line, labels[label_values[0]], col_idx + left_margin + offset_frac
408
+ )
262
409
 
263
410
  label_values = label_values[1:] # pop that first label since we added it
264
411
 
265
412
  return "".join(tick_line), "".join(label_line)
413
+
414
+ def upscale(self, new_num_bins, multiplier):
415
+ """Adjust axis for an upscaled plot"""
416
+ if self.values_are_edges:
417
+ # upscaling doesn't move the axis edges. First edge of first bin is the same.
418
+ return
419
+ old_num_bins = new_num_bins // multiplier
420
+ old_bin_width = (self.value_range.max - self.value_range.min) / (old_num_bins - 1)
421
+ new_bin_width = old_bin_width / multiplier
422
+ # we used to have a value for the center of the first bin, but now that bin is
423
+ # 'multiplier' bins, and the value corresponds to the center of that _group_ of bins
424
+ # Find the offset between that group center, and the center of the new edge bin:
425
+ offset = new_bin_width * (multiplier - 1) / 2
426
+ self.value_range = util.make_value_range(
427
+ (self.value_range.min - offset, self.value_range.max + offset)
428
+ )