plotille 6.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.

Potentially problematic release.


This version of plotille might be problematic. Click here for more details.

plotille/_figure.py ADDED
@@ -0,0 +1,982 @@
1
+ # The MIT License
2
+
3
+ # Copyright (c) 2017 - 2025 Tammo Ippen, tammo.ippen@posteo.de
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
13
+ # all 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
21
+ # THE SOFTWARE.
22
+
23
+ import os
24
+ import sys
25
+ from collections.abc import Callable, Iterator, Sequence
26
+ from datetime import timedelta, tzinfo
27
+ from itertools import cycle
28
+
29
+ if sys.version_info >= (3, 11):
30
+ from typing import Any, Final, Literal, NotRequired, TypedDict
31
+ else:
32
+ from typing import Any, Final, Literal, TypedDict
33
+
34
+ from typing_extensions import NotRequired
35
+
36
+ from ._canvas import Canvas
37
+ from ._cmaps import Colormap
38
+ from ._colors import ColorDefinition, ColorMode, color, rgb2byte
39
+ from ._data_metadata import DataMetadata
40
+ from ._figure_data import Heat, HeatInput, Histogram, Plot, Span, Text
41
+ from ._input_formatter import Converter, Formatter, InputFormatter
42
+ from ._util import DataValue, DataValues
43
+
44
+ """Figure class for composing plots.
45
+
46
+ Architecture Note:
47
+ ------------------
48
+ The Figure class manages plot composition and rendering. It works internally
49
+ with normalized float values:
50
+
51
+ - All limit calculations use float
52
+ - Axis generation uses float
53
+ - Canvas operations use float
54
+
55
+ The public API (plot, scatter, histogram, text methods) accepts both
56
+ numeric and datetime. Conversion to float happens in the data container
57
+ classes (Plot, Text, Histogram).
58
+ """
59
+
60
+ # TODO documentation!!!
61
+ # TODO tests
62
+
63
+
64
+ class _ColorKwargs(TypedDict):
65
+ fg: NotRequired[ColorDefinition]
66
+ bg: NotRequired[ColorDefinition]
67
+ mode: ColorMode
68
+ no_color: NotRequired[bool]
69
+ full_reset: NotRequired[bool]
70
+
71
+
72
+ class Figure:
73
+ """Figure class to compose multiple plots.
74
+
75
+ Within a Figure you can easily compose many plots, assign labels to plots
76
+ and define the properties of the underlying Canvas. Possible properties that
77
+ can be defined are:
78
+
79
+ width, height: int Define the number of characters in X / Y direction
80
+ which are used for plotting.
81
+ x_limits: DataValue Define the X limits of the reference coordinate system,
82
+ that will be plotted.
83
+ y_limits: DataValue Define the Y limits of the reference coordinate system,
84
+ that will be plotted.
85
+ color_mode: str Define the used color mode. See `plotille.color()`.
86
+ with_colors: bool Define, whether to use colors at all.
87
+ background: ColorDefinition Define the background color.
88
+ x_label, y_label: str Define the X / Y axis label.
89
+ """
90
+
91
+ _COLOR_SEQ: Final[list[dict[ColorMode, ColorDefinition]]] = [
92
+ {"names": "white", "rgb": (255, 255, 255), "byte": rgb2byte(255, 255, 255)},
93
+ {"names": "red", "rgb": (255, 0, 0), "byte": rgb2byte(255, 0, 0)},
94
+ {"names": "green", "rgb": (0, 255, 0), "byte": rgb2byte(0, 255, 0)},
95
+ {"names": "yellow", "rgb": (255, 255, 0), "byte": rgb2byte(255, 255, 0)},
96
+ {"names": "blue", "rgb": (0, 0, 255), "byte": rgb2byte(0, 0, 255)},
97
+ {"names": "magenta", "rgb": (255, 0, 255), "byte": rgb2byte(255, 0, 255)},
98
+ {"names": "cyan", "rgb": (0, 255, 255), "byte": rgb2byte(0, 255, 255)},
99
+ ]
100
+
101
+ def __init__(self) -> None:
102
+ self._color_seq: Iterator[dict[ColorMode, ColorDefinition]] = iter(
103
+ cycle(Figure._COLOR_SEQ)
104
+ )
105
+ self._width: int | None = None
106
+ self._height: int | None = None
107
+ self._x_min: float | None = None
108
+ self._x_max: float | None = None
109
+ self._y_min: float | None = None
110
+ self._y_max: float | None = None
111
+ self._color_kwargs: _ColorKwargs = {"mode": "names"}
112
+ self._with_colors: bool = True
113
+ self._origin: bool = True
114
+ self.linesep: str = os.linesep
115
+ self.background: ColorDefinition = None
116
+ self.x_label: str = "X"
117
+ self.y_label: str = "Y"
118
+ # min, max -> value
119
+ self.y_ticks_fkt: Callable[[DataValue, DataValue], DataValue | str] | None = (
120
+ None
121
+ )
122
+ self.x_ticks_fkt: Callable[[DataValue, DataValue], DataValue | str] | None = (
123
+ None
124
+ )
125
+ self._plots: list[Plot | Histogram] = []
126
+ self._texts: list[Text] = []
127
+ self._spans: list[Span] = []
128
+ self._heats: list[Heat] = []
129
+ self._in_fmt: InputFormatter = InputFormatter()
130
+
131
+ # Metadata for axis display formatting
132
+ self._x_display_metadata: DataMetadata | None = None
133
+ self._y_display_metadata: DataMetadata | None = None
134
+ self._x_display_timezone_override: tzinfo | None = None
135
+ self._y_display_timezone_override: tzinfo | None = None
136
+
137
+ @property
138
+ def width(self) -> int:
139
+ if self._width is not None:
140
+ return self._width
141
+ return 80
142
+
143
+ @width.setter
144
+ def width(self, value: int) -> None:
145
+ if not (isinstance(value, int) and value > 0):
146
+ raise ValueError(f"Invalid width: {value}")
147
+ self._width = value
148
+
149
+ @property
150
+ def height(self) -> int:
151
+ if self._height is not None:
152
+ return self._height
153
+ return 40
154
+
155
+ @height.setter
156
+ def height(self, value: int) -> None:
157
+ if not (isinstance(value, int) and value > 0):
158
+ raise ValueError(f"Invalid height: {value}")
159
+ self._height = value
160
+
161
+ @property
162
+ def color_mode(self) -> ColorMode:
163
+ return self._color_kwargs["mode"]
164
+
165
+ @color_mode.setter
166
+ def color_mode(self, value: ColorMode) -> None:
167
+ if value not in ("names", "byte", "rgb"):
168
+ raise ValueError("Only supports: names, byte, rgb!")
169
+ if self._plots != []:
170
+ raise RuntimeError("Change color mode only, when no plots are prepared.")
171
+ self._color_kwargs["mode"] = value
172
+
173
+ @property
174
+ def color_full_reset(self) -> bool:
175
+ return self._color_kwargs.get("full_reset", True)
176
+
177
+ @color_full_reset.setter
178
+ def color_full_reset(self, value: bool) -> None:
179
+ if not isinstance(value, bool):
180
+ raise TypeError("Only supports bool.")
181
+ self._color_kwargs["full_reset"] = value
182
+
183
+ @property
184
+ def with_colors(self) -> bool:
185
+ """Whether to plot with or without color."""
186
+ return self._with_colors
187
+
188
+ @with_colors.setter
189
+ def with_colors(self, value: bool) -> None:
190
+ if not isinstance(value, bool):
191
+ raise TypeError(f'Only bool allowed: "{value}"')
192
+ self._with_colors = value
193
+
194
+ @property
195
+ def origin(self) -> bool:
196
+ """Show or not show the origin in the plot."""
197
+ return self._origin
198
+
199
+ @origin.setter
200
+ def origin(self, value: bool) -> None:
201
+ if not isinstance(value, bool):
202
+ raise TypeError(f"Invalid origin: {value}")
203
+ self._origin = value
204
+
205
+ def _aggregate_metadata(self, is_height: bool) -> DataMetadata | None:
206
+ """Aggregate metadata from all plots for one axis.
207
+
208
+ Determines whether the axis should display as numeric or datetime,
209
+ and validates that all plots have compatible types.
210
+
211
+ Args:
212
+ is_height: True for Y-axis, False for X-axis
213
+
214
+ Returns:
215
+ DataMetadata for the axis (with display timezone), or None if no plots
216
+
217
+ Raises:
218
+ ValueError: If plots have incompatible types on same axis
219
+ """
220
+ # Collect metadata from all plots
221
+ metadatas = []
222
+ for p in self._plots + self._texts:
223
+ if is_height:
224
+ metadatas.append(p.Y_metadata)
225
+ else:
226
+ metadatas.append(p.X_metadata)
227
+
228
+ if not metadatas:
229
+ # No plots yet, no metadata to aggregate
230
+ return None
231
+
232
+ datetime_flags = {m.is_datetime for m in metadatas}
233
+ if len(datetime_flags) > 1:
234
+ axis_name = "Y" if is_height else "X"
235
+ raise ValueError(
236
+ f"Cannot mix numeric and datetime values on {axis_name}-axis. "
237
+ f"All plots on an axis must use the same data type."
238
+ )
239
+
240
+ if not metadatas[0].is_datetime:
241
+ return DataMetadata(is_datetime=False, timezone=None)
242
+
243
+ timezones = {m.timezone for m in metadatas}
244
+ has_naive = None in timezones
245
+ has_aware = len(timezones - {None}) > 0
246
+
247
+ # Cannot mix naive and aware datetime
248
+ if has_naive and has_aware:
249
+ axis_name = "Y" if is_height else "X"
250
+ raise ValueError(
251
+ f"Cannot mix timezone-naive and timezone-aware datetime on {axis_name}-axis. "
252
+ f"Either all datetimes must have timezones or none must have timezones. "
253
+ f"Found: {timezones}"
254
+ )
255
+
256
+ # Pick first encountered timezone as default
257
+ # (User can override with set_x_display_timezone/set_y_display_timezone)
258
+ display_timezone = metadatas[0].timezone
259
+
260
+ return DataMetadata(is_datetime=True, timezone=display_timezone)
261
+
262
+ def set_x_display_timezone(self, tz: tzinfo | None) -> None:
263
+ """Set display timezone for X-axis labels.
264
+
265
+ Use this when you have datetime data with multiple timezones and want
266
+ to display the axis in a specific timezone.
267
+
268
+ Args:
269
+ tz: Target timezone (e.g., ZoneInfo("America/New_York"), timezone.utc)
270
+ or None for naive datetime display
271
+
272
+ Example:
273
+ from zoneinfo import ZoneInfo
274
+ fig.set_x_display_timezone(ZoneInfo("America/New_York"))
275
+ """
276
+ self._x_display_timezone_override = tz
277
+
278
+ def set_y_display_timezone(self, tz: tzinfo | None) -> None:
279
+ """Set display timezone for Y-axis labels.
280
+
281
+ Use this when you have datetime data with multiple timezones and want
282
+ to display the axis in a specific timezone.
283
+
284
+ Args:
285
+ tz: Target timezone (e.g., ZoneInfo("America/New_York"), timezone.utc)
286
+ or None for naive datetime display
287
+
288
+ Example:
289
+ from zoneinfo import ZoneInfo
290
+ fig.set_y_display_timezone(ZoneInfo("UTC"))
291
+ """
292
+ self._y_display_timezone_override = tz
293
+
294
+ def register_label_formatter(self, type_: type[Any], formatter: Formatter) -> None:
295
+ """Register a formatter for labels of a certain type.
296
+
297
+ See `plotille._input_formatter` for examples.
298
+
299
+ Parameters
300
+ ----------
301
+ type_
302
+ A python type, that can be used for isinstance tests.
303
+ formatter: (val: type_, chars: int, delta, left: bool = False) -> str
304
+ Function that formats `val` into a string.
305
+ chars: int => number of chars you should fill
306
+ delta => the difference between the smallest and largest X/Y value
307
+ left: bool => align left or right.
308
+ """
309
+ self._in_fmt.register_formatter(type_, formatter)
310
+
311
+ def register_float_converter(self, type_: type[Any], converter: Converter) -> None:
312
+ """Register a converter from some type_ to float.
313
+
314
+ See `plotille._input_formatter` for examples.
315
+
316
+ Parameters
317
+ ----------
318
+ type_
319
+ A python type, that can be used for isinstance tests.
320
+ formatter: (val: type_) -> float
321
+ Function that formats `val` into a float.
322
+ """
323
+ self._in_fmt.register_converter(type_, converter)
324
+
325
+ def x_limits(self) -> tuple[float, float]:
326
+ """Get the X-axis limits as normalized floats."""
327
+ return self._limits(self._x_min, self._x_max, False)
328
+
329
+ def set_x_limits(
330
+ self, min_: DataValue | None = None, max_: DataValue | None = None
331
+ ) -> None:
332
+ """Set min and max X values for displaying.
333
+
334
+ Args:
335
+ min_: Minimum X value (can be datetime or numeric)
336
+ max_: Maximum X value (can be datetime or numeric)
337
+
338
+ Note: Values will be normalized to float internally.
339
+ """
340
+ values = [v for v in [min_, max_] if v is not None]
341
+ if values:
342
+ self._x_display_metadata = DataMetadata.from_sequence(values)
343
+
344
+ min_float = self._in_fmt.convert(min_) if min_ is not None else None
345
+ max_float = self._in_fmt.convert(max_) if max_ is not None else None
346
+
347
+ self._x_min, self._x_max = self._set_limits(
348
+ self._x_min, self._x_max, min_float, max_float
349
+ )
350
+
351
+ def y_limits(self) -> tuple[float, float]:
352
+ """Get the Y-axis limits as normalized floats."""
353
+ return self._limits(self._y_min, self._y_max, True)
354
+
355
+ def set_y_limits(
356
+ self, min_: DataValue | None = None, max_: DataValue | None = None
357
+ ) -> None:
358
+ """Set min and max Y values for displaying.
359
+
360
+ Args:
361
+ min_: Minimum Y value (can be datetime or numeric)
362
+ max_: Maximum Y value (can be datetime or numeric)
363
+
364
+ Note: Values will be normalized to float internally.
365
+ """
366
+ values = [v for v in [min_, max_] if v is not None]
367
+ if values:
368
+ self._y_display_metadata = DataMetadata.from_sequence(values)
369
+
370
+ min_float = self._in_fmt.convert(min_) if min_ is not None else None
371
+ max_float = self._in_fmt.convert(max_) if max_ is not None else None
372
+
373
+ self._y_min, self._y_max = self._set_limits(
374
+ self._y_min, self._y_max, min_float, max_float
375
+ )
376
+
377
+ def _set_limits(
378
+ self,
379
+ init_min: float | None,
380
+ init_max: float | None,
381
+ min_: float | None = None,
382
+ max_: float | None = None,
383
+ ) -> tuple[float | None, float | None]:
384
+ """Set limits for an axis.
385
+
386
+ All parameters are already normalized to float.
387
+
388
+ Args:
389
+ init_min: Current minimum value
390
+ init_max: Current maximum value
391
+ min_: New minimum value (if setting)
392
+ max_: New maximum value (if setting)
393
+
394
+ Returns:
395
+ (min, max) tuple of floats or Nones
396
+ """
397
+ values = list(filter(lambda v: v is not None, [init_min, init_max, min_, max_]))
398
+ if not values:
399
+ return None, None
400
+
401
+ if min_ is not None and max_ is not None:
402
+ if min_ >= max_:
403
+ raise ValueError("min_ is larger or equal than max_.")
404
+ init_min = min_
405
+ init_max = max_
406
+ elif min_ is not None:
407
+ if init_max is not None and min_ >= init_max:
408
+ raise ValueError("Previous max is smaller or equal to new min_.")
409
+ init_min = min_
410
+ elif max_ is not None:
411
+ if init_min is not None and init_min >= max_:
412
+ raise ValueError("Previous min is larger or equal to new max_.")
413
+ init_max = max_
414
+ else:
415
+ init_min = None
416
+ init_max = None
417
+
418
+ return init_min, init_max
419
+
420
+ def _limits(
421
+ self, low_set: float | None, high_set: float | None, is_height: bool
422
+ ) -> tuple[float, float]:
423
+ """Calculate the limits for an axis.
424
+
425
+ Aggregates metadata from all plots and works with normalized float values.
426
+
427
+ Args:
428
+ low_set: User-specified minimum value (already converted to float)
429
+ high_set: User-specified maximum value (already converted to float)
430
+ is_height: True for Y-axis, False for X-axis
431
+
432
+ Returns:
433
+ (min, max) as floats
434
+ """
435
+ # Aggregate and store metadata for this axis
436
+ metadata = self._aggregate_metadata(is_height)
437
+ if metadata is not None:
438
+ if is_height:
439
+ self._y_display_metadata = metadata
440
+ else:
441
+ self._x_display_metadata = metadata
442
+
443
+ if low_set is not None and high_set is not None:
444
+ return low_set, high_set
445
+
446
+ # Get limits from normalized data (all floats)
447
+ low, high = None, None
448
+ for p in self._plots + self._texts:
449
+ if is_height:
450
+ _min, _max = _limit(p.height_vals())
451
+ else:
452
+ _min, _max = _limit(p.width_vals())
453
+ if low is None or high is None:
454
+ low = _min
455
+ high = _max
456
+ else:
457
+ low = min(_min, low)
458
+ high = max(_max, high)
459
+
460
+ # Calculate final limits
461
+ result = _choose(low, high, low_set, high_set)
462
+ return result
463
+
464
+ def _y_axis(self, ymin: float, ymax: float, label: str = "Y") -> list[str]:
465
+ """Generate Y-axis labels.
466
+
467
+ Uses stored metadata to convert float values back to display format
468
+ (datetime or numeric).
469
+
470
+ Args:
471
+ ymin: Minimum Y value (as normalized float/timestamp)
472
+ ymax: Maximum Y value (as normalized float/timestamp)
473
+ label: Axis label
474
+
475
+ Returns:
476
+ List of formatted axis labels
477
+ """
478
+ if self._y_display_metadata is None:
479
+ self._y_display_metadata = DataMetadata(is_datetime=False, timezone=None)
480
+
481
+ delta = abs(ymax - ymin)
482
+ y_delta = delta / self.height
483
+
484
+ # Convert delta for display formatting
485
+ delta_display = (
486
+ timedelta(seconds=delta) if self._y_display_metadata.is_datetime else delta
487
+ )
488
+
489
+ res = []
490
+ for i in range(self.height):
491
+ value_float = i * y_delta + ymin
492
+
493
+ # Convert to display type using metadata
494
+ value_display = self._y_display_metadata.convert_for_display(
495
+ value_float, self._y_display_timezone_override
496
+ )
497
+
498
+ if self.y_ticks_fkt:
499
+ value_display = self.y_ticks_fkt(value_display, value_display) # type: ignore[assignment]
500
+
501
+ res += [self._in_fmt.fmt(value_display, delta_display, chars=10) + " | "]
502
+
503
+ # add max separately
504
+ value_float = self.height * y_delta + ymin
505
+ value_display = self._y_display_metadata.convert_for_display(
506
+ value_float, self._y_display_timezone_override
507
+ )
508
+
509
+ if self.y_ticks_fkt:
510
+ value_display = self.y_ticks_fkt(value_display, value_display) # type: ignore[assignment]
511
+
512
+ res += [self._in_fmt.fmt(value_display, delta_display, chars=10) + " |"]
513
+
514
+ ylbl = f"({label})"
515
+ ylbl_left = (10 - len(ylbl)) // 2
516
+ ylbl_right = ylbl_left + len(ylbl) % 2
517
+
518
+ res += [" " * (ylbl_left) + ylbl + " " * (ylbl_right) + " ^"]
519
+ return list(reversed(res))
520
+
521
+ def _x_axis(
522
+ self, xmin: float, xmax: float, label: str = "X", with_y_axis: bool = False
523
+ ) -> list[str]:
524
+ """Generate X-axis labels.
525
+
526
+ Uses stored metadata to convert float values back to display format
527
+ (datetime or numeric).
528
+
529
+ Args:
530
+ xmin: Minimum X value (as normalized float/timestamp)
531
+ xmax: Maximum X value (as normalized float/timestamp)
532
+ label: Axis label
533
+ with_y_axis: Whether to add spacing for Y-axis labels
534
+
535
+ Returns:
536
+ List of formatted axis labels
537
+ """
538
+ meta = self._x_display_metadata
539
+ if meta is None:
540
+ meta = DataMetadata(is_datetime=False, timezone=None)
541
+
542
+ delta = abs(xmax - xmin)
543
+ x_delta = delta / self.width
544
+
545
+ # Convert delta for display formatting
546
+ delta_display = timedelta(seconds=delta) if meta.is_datetime else delta
547
+
548
+ starts = ["", ""]
549
+ if with_y_axis:
550
+ starts = ["-" * 11 + "|-", " " * 11 + "| "]
551
+ res = []
552
+
553
+ res += [
554
+ starts[0]
555
+ + "|---------" * (self.width // 10)
556
+ + "|"
557
+ + "-" * (self.width % 10)
558
+ + "-> ("
559
+ + label
560
+ + ")"
561
+ ]
562
+ bottom = []
563
+
564
+ for i in range(self.width // 10 + 1):
565
+ value_float = i * 10 * x_delta + xmin
566
+
567
+ # Convert to display type using metadata
568
+ value_display = meta.convert_for_display(
569
+ value_float, self._x_display_timezone_override
570
+ )
571
+
572
+ if self.x_ticks_fkt:
573
+ value_display = self.x_ticks_fkt(value_display, value_display) # type: ignore[assignment]
574
+
575
+ bottom += [
576
+ self._in_fmt.fmt(value_display, delta_display, left=True, chars=9)
577
+ ]
578
+
579
+ res += [starts[1] + " ".join(bottom)]
580
+ return res
581
+
582
+ def clear(self) -> None:
583
+ """Remove all plots, texts and spans from the figure."""
584
+ self._plots = []
585
+ self._texts = []
586
+ self._spans = []
587
+ self._heats = []
588
+
589
+ def plot(
590
+ self,
591
+ X: DataValues,
592
+ Y: DataValues,
593
+ lc: ColorDefinition = None,
594
+ interp: Literal["linear"] | None = "linear",
595
+ label: str | None = None,
596
+ marker: str | None = None,
597
+ ) -> None:
598
+ """Create plot with X, Y values.
599
+
600
+ X and Y can contain either numeric values (int, float) or datetime values,
601
+ but not both in the same array. Data is normalized to float internally
602
+ for efficient processing.
603
+
604
+ Parameters:
605
+ X: DataValues
606
+ X values. Can be numeric or datetime, but must be consistent.
607
+ Y: DataValues
608
+ Y values. X and Y must have the same number of entries.
609
+ lc: ColorDefinition
610
+ The line color.
611
+ interp: str
612
+ The interpolation method. (None or 'linear').
613
+ label: str
614
+ The label for the legend.
615
+ marker: str
616
+ Instead of braille dots set a marker char.
617
+ """
618
+ if len(X) > 0:
619
+ if lc is None:
620
+ lc = next(self._color_seq)[self.color_mode]
621
+ self._plots += [Plot(X, Y, lc, interp, label, marker, self._in_fmt)]
622
+
623
+ def scatter(
624
+ self,
625
+ X: DataValues,
626
+ Y: DataValues,
627
+ lc: ColorDefinition = None,
628
+ label: str | None = None,
629
+ marker: str | None = None,
630
+ ) -> None:
631
+ """Create a scatter plot with X, Y values.
632
+
633
+ X and Y can contain either numeric values (int, float) or datetime values,
634
+ but not both in the same array. Data is normalized to float internally
635
+ for efficient processing.
636
+
637
+ Parameters:
638
+ X: DataValues
639
+ X values. Can be numeric or datetime, but must be consistent.
640
+ Y: DataValues
641
+ Y values. X and Y must have the same number of entries.
642
+ lc: ColorDefinition
643
+ The line color.
644
+ label: str
645
+ The label for the legend.
646
+ marker: str
647
+ Instead of braille dots set a marker char.
648
+ """
649
+ if len(X) > 0:
650
+ if lc is None:
651
+ lc = next(self._color_seq)[self.color_mode]
652
+ self._plots += [Plot(X, Y, lc, None, label, marker, self._in_fmt)]
653
+
654
+ def histogram(
655
+ self, X: DataValues, bins: int = 160, lc: ColorDefinition = None
656
+ ) -> None:
657
+ """Compute and plot the histogram over X.
658
+
659
+ X can contain either numeric values (e.g. int, float) or datetime values.
660
+ Data is normalized to float internally for efficient processing.
661
+
662
+ Parameters:
663
+ X: DataValues
664
+ X values. Can be numeric or datetime.
665
+ bins: int
666
+ The number of bins to put X entries in (columns).
667
+ lc: ColorDefinition
668
+ The line color.
669
+ """
670
+ if len(X) > 0:
671
+ if lc is None:
672
+ lc = next(self._color_seq)[self.color_mode]
673
+ self._plots += [Histogram(X, bins, lc)]
674
+
675
+ def text(
676
+ self,
677
+ X: DataValues,
678
+ Y: DataValues,
679
+ texts: Sequence[str],
680
+ lc: ColorDefinition = None,
681
+ ) -> None:
682
+ """Plot texts at coordinates X, Y.
683
+
684
+ Always print the first character of a text at its
685
+ x, y coordinate and continue to the right. Character
686
+ extending the canvas are cut.
687
+
688
+ X and Y can contain either numeric values (int, float) or datetime values,
689
+ but not both in the same array. Data is normalized to float internally
690
+ for efficient processing.
691
+
692
+ Parameters:
693
+ X: DataValues
694
+ X values. Can be numeric or datetime, but must be consistent.
695
+ Y: DataValues
696
+ Y values.
697
+ texts: Sequence[str]
698
+ Texts to print. X, Y and texts must have the same number of entries.
699
+ lc: ColorDefinition
700
+ The (text) line color.
701
+ """
702
+ if len(X) > 0:
703
+ self._texts += [Text(X, Y, texts, lc, self._in_fmt)]
704
+
705
+ def axvline(
706
+ self, x: float, ymin: float = 0, ymax: float = 1, lc: ColorDefinition = None
707
+ ) -> None:
708
+ """Plot a vertical line at x.
709
+
710
+ Parameters:
711
+ x: float x-coordinate of the vertical line.
712
+ In the range [0, 1]
713
+ ymin: float Minimum y-coordinate of the vertical line.
714
+ In the range [0, 1]
715
+ ymax: float Maximum y-coordinate of the vertical line.
716
+ In the range [0, 1]
717
+ lc: ColorDefinition The line color.
718
+ """
719
+ self._spans.append(Span(x, x, ymin, ymax, lc))
720
+
721
+ def axvspan(
722
+ self,
723
+ xmin: float,
724
+ xmax: float,
725
+ ymin: float = 0,
726
+ ymax: float = 1,
727
+ lc: ColorDefinition = None,
728
+ ) -> None:
729
+ """Plot a vertical rectangle from (xmin,ymin) to (xmax, ymax).
730
+
731
+ Parameters:
732
+ xmin: float Minimum x-coordinate of the rectangle.
733
+ In the range [0, 1]
734
+ xmax: float Maximum x-coordinate of the rectangle.
735
+ In the range [0, 1]
736
+ ymin: float Minimum y-coordinate of the rectangle.
737
+ In the range [0, 1]
738
+ ymax: float Maximum y-coordinate of the rectangle.
739
+ In the range [0, 1]
740
+ lc: ColorDefinition The line color.
741
+ """
742
+ self._spans.append(Span(xmin, xmax, ymin, ymax, lc))
743
+
744
+ def axhline(
745
+ self, y: float, xmin: float = 0, xmax: float = 1, lc: ColorDefinition = None
746
+ ) -> None:
747
+ """Plot a horizontal line at y.
748
+
749
+ Parameters:
750
+ y: float y-coordinate of the horizontal line.
751
+ In the range [0, 1]
752
+ x_min: float Minimum x-coordinate of the vertical line.
753
+ In the range [0, 1]
754
+ x_max: float Maximum x-coordinate of the vertical line.
755
+ In the range [0, 1]
756
+ lc: ColorDefinition The line color.
757
+ """
758
+ self._spans.append(Span(xmin, xmax, y, y, lc))
759
+
760
+ def axhspan(
761
+ self,
762
+ ymin: float,
763
+ ymax: float,
764
+ xmin: float = 0,
765
+ xmax: float = 1,
766
+ lc: ColorDefinition = None,
767
+ ) -> None:
768
+ """Plot a horizontal rectangle from (xmin,ymin) to (xmax, ymax).
769
+
770
+ Parameters:
771
+ ymin: float Minimum y-coordinate of the rectangle.
772
+ In the range [0, 1]
773
+ ymax: float Maximum y-coordinate of the rectangle.
774
+ In the range [0, 1]
775
+ xmin: float Minimum x-coordinate of the rectangle.
776
+ In the range [0, 1]
777
+ xmax: float Maximum x-coordinate of the rectangle.
778
+ In the range [0, 1]
779
+ lc: ColorDefinition The line color.
780
+ """
781
+ self._spans.append(Span(xmin, xmax, ymin, ymax, lc))
782
+
783
+ def imgshow(self, X: HeatInput, cmap: str | Colormap | None = None) -> None:
784
+ """Display data as an image, i.e., on a 2D regular raster.
785
+
786
+ Parameters:
787
+ X: array-like
788
+ The image data. Supported array shapes are:
789
+ - (M, N): an image with scalar data. The values are mapped
790
+ to colors using a colormap. The values have to be in
791
+ the 0-1 (float) range. Out of range, invalid type and
792
+ None values are handled by the cmap.
793
+ - (M, N, 3): an image with RGB values (0-1 float or 0-255 int).
794
+
795
+ The first two dimensions (M, N) define the rows and columns of the
796
+ image.
797
+
798
+ cmap: cmapstr or Colormap
799
+ The Colormap instance or registered colormap name used
800
+ to map scalar data to colors. This parameter is ignored
801
+ for RGB data.
802
+ """
803
+ if len(X) > 0:
804
+ self._heats += [Heat(X, cmap)]
805
+
806
+ def show(self, legend: bool = False) -> str:
807
+ """Compute the plot.
808
+
809
+ Parameters:
810
+ legend: bool Add the legend? default: False
811
+
812
+ Returns:
813
+ plot: str
814
+ """
815
+ xmin, xmax = self.x_limits()
816
+ ymin, ymax = self.y_limits()
817
+ if self._plots and all(isinstance(p, Histogram) for p in self._plots):
818
+ ymin = 0.0
819
+
820
+ if self._heats and self._width is None and self._height is None:
821
+ self.height = len(self._heats[0].X)
822
+ self.width = len(self._heats[0].X[0])
823
+
824
+ # create canvas
825
+ canvas = Canvas(
826
+ self.width,
827
+ self.height,
828
+ xmin,
829
+ ymin,
830
+ xmax,
831
+ ymax,
832
+ self.background,
833
+ **self._color_kwargs,
834
+ )
835
+
836
+ for s in self._spans:
837
+ s.write(canvas, self.with_colors)
838
+
839
+ plot_origin = False
840
+ for p in self._plots:
841
+ p.write(canvas, self.with_colors, self._in_fmt)
842
+ if isinstance(p, Plot):
843
+ plot_origin = True
844
+
845
+ for t in self._texts:
846
+ t.write(canvas, self.with_colors, self._in_fmt)
847
+
848
+ for h in self._heats:
849
+ h.write(canvas)
850
+
851
+ if self.origin and plot_origin:
852
+ # print X / Y origin axis
853
+ canvas.line(xmin, 0.0, xmax, 0.0)
854
+ canvas.line(0.0, ymin, 0.0, ymax)
855
+
856
+ res = canvas.plot(linesep=self.linesep)
857
+
858
+ # add y axis
859
+ yaxis = self._y_axis(ymin, ymax, label=self.y_label)
860
+ res = (
861
+ yaxis[0]
862
+ + self.linesep # up arrow
863
+ + yaxis[1]
864
+ + self.linesep # maximum
865
+ + self.linesep.join(
866
+ lbl + line
867
+ for lbl, line in zip(yaxis[2:], res.split(self.linesep), strict=True)
868
+ )
869
+ )
870
+
871
+ # add x axis
872
+ xaxis = self._x_axis(xmin, xmax, label=self.x_label, with_y_axis=True)
873
+ res = (
874
+ res
875
+ + self.linesep # plot
876
+ + self.linesep.join(xaxis)
877
+ )
878
+
879
+ if legend:
880
+ res += f"{self.linesep}{self.linesep}Legend:{self.linesep}-------{self.linesep}"
881
+ lines = []
882
+ for i, p in enumerate(self._plots):
883
+ if isinstance(p, Plot):
884
+ lbl = p.label or f"Label {i}"
885
+ marker = p.marker or ""
886
+ lines += [
887
+ color(
888
+ f"⠤{marker}⠤ {lbl}",
889
+ fg=p.lc,
890
+ mode=self.color_mode,
891
+ no_color=not self.with_colors,
892
+ )
893
+ ]
894
+ res += self.linesep.join(lines)
895
+ return res
896
+
897
+
898
+ def _limit(values: Sequence[float]) -> tuple[float, float]:
899
+ """Find min and max of normalized float values.
900
+
901
+ Args:
902
+ values: Sequence of already-normalized float values
903
+
904
+ Returns:
905
+ (min, max) as floats
906
+ """
907
+ min_: float = 0.0
908
+ max_: float = 1.0
909
+ if len(values) > 0:
910
+ min_ = min(values)
911
+ max_ = max(values)
912
+
913
+ return min_, max_
914
+
915
+
916
+ def _diff(low: float, high: float) -> float:
917
+ # assert type(low) is type(high)
918
+ if low == high:
919
+ if low == 0:
920
+ return 0.5
921
+ else:
922
+ return abs(low * 0.1)
923
+ else:
924
+ delta = abs(high - low)
925
+ return delta * 0.1
926
+
927
+
928
+ def _default(low_set: float | None, high_set: float | None) -> tuple[float, float]:
929
+ if low_set is None and high_set is None:
930
+ return 0.0, 1.0 # defaults
931
+
932
+ if low_set is None and high_set is not None:
933
+ if high_set <= 0:
934
+ return high_set - 1, high_set
935
+ else:
936
+ return 0.0, high_set
937
+
938
+ if low_set is not None and high_set is None:
939
+ if low_set >= 1:
940
+ return low_set, low_set + 1
941
+ else:
942
+ return low_set, 1.0
943
+
944
+ # Should never get here! => checked in function before
945
+ raise ValueError("Unexpected inputs!")
946
+
947
+
948
+ def _choose(
949
+ low: float | None, high: float | None, low_set: float | None, high_set: float | None
950
+ ) -> tuple[float, float]:
951
+ if low is None or high is None:
952
+ # either all are set or none
953
+ assert low is None
954
+ assert high is None
955
+ return _default(low_set, high_set)
956
+
957
+ else: # some data
958
+ if low_set is None and high_set is None:
959
+ # no restrictions from user, use low & high
960
+ diff = _diff(low, high)
961
+ return low - diff, high + diff
962
+
963
+ if low_set is None and high_set is not None:
964
+ # user sets high end
965
+ if high_set < low:
966
+ # high is smaller than lowest value
967
+ return high_set - 1, high_set
968
+
969
+ diff = _diff(low, high_set)
970
+ return low - diff, high_set
971
+
972
+ if low_set is not None and high_set is None:
973
+ # user sets low end
974
+ if low_set > high:
975
+ # low is larger than highest value
976
+ return low_set, low_set + 1
977
+
978
+ diff = _diff(low_set, high)
979
+ return low_set, high + diff
980
+
981
+ # Should never get here! => checked in function before
982
+ raise ValueError("Unexpected inputs!")