Source code for mytk.dataviews

from math import ceil, floor, log10

from .canvasview import Arrow, CanvasElement, CanvasLabel, Line, Oval
from .vectors import Basis, Point, PointDefault, Vector, is_standard_basis


[docs] class DataPoint(Oval): """A circular data point drawn as an oval on a canvas.""" def __init__(self, size: float, **kwargs): super().__init__(size=(size, size), **kwargs)
[docs] class Function(CanvasElement): """A canvas element that draws a mathematical function as a polyline.""" def __init__(self, fct, xs, basis=None, **kwarg): super().__init__(**kwarg) self.fct = fct self.xs = xs self.basis = basis self.line = None
[docs] def create(self, canvas, position=None): """Create the function polyline on the given canvas.""" if position is None: position = Point(0, 0) self.canvas = canvas self.id = "my_function" self.add_group_tag(f"group-{self.id}") with PointDefault(basis=self.basis): points = [ Point(x, self.fct(x)) for x in self.xs] canvas_points = [ point.standard_coordinates() for point in points] self.line = Line(points=canvas_points, smooth=False, **self._element_kwargs) self.line.create(canvas, position.standard_coordinates()) self.line.add_group_tag(f"group-{self.id}")
[docs] class XYCoordinateSystemElement(CanvasElement): """An XY coordinate system with axes, ticks, and labels drawn on a canvas.""" def __init__(self, size=None, normalized_size=None, axes_limits=((0, 1), (0, 1)), **kwargs): super().__init__(**kwargs) if size is None and normalized_size is None: raise ValueError("You must set size or normalized size") self.size = size self.normalized_size = normalized_size self.axes_limits = axes_limits self.nx_major = 10 self.ny_major = 10 self.is_clipping = True self.x_axis_at_bottom = True # All lengths are relative to (line) width self.major_length = 4 self.tick_text_size = 8 self.tick_value_offset = 2 self.x_format = "{0:.0f}" self.y_format = "{0:.0f}" @property def basis(self): """Compute the coordinate basis from axes limits and canvas size.""" x_lims = self.axes_limits[0] y_lims = self.axes_limits[1] size_vector_x = self.size[0].standard_coordinates() size_vector_y = self.size[1].standard_coordinates() return Basis(Vector(size_vector_x.c0 / (x_lims[1] - x_lims[0]), 0), Vector(0, size_vector_y.c1 / (y_lims[1] - y_lims[0]))) @basis.setter def basis(self, new_value): if new_value is not None and not is_standard_basis(new_value): print('Warning: cannot set basis in XYCoordinate system. Set size or axes_limits instead.')
[docs] def create(self, canvas, position=None): """Create the full coordinate system on the given canvas.""" if position is None: position = Point(0, 0) self.canvas = canvas self.reference_point = position self.id = "xy_coords" self.add_group_tag(f"group-{self.id}") _width = self._element_kwargs.get("width", 1) self.create_x_axis() self.create_x_major_ticks() self.create_x_major_ticks_labels() self.create_y_axis() self.create_y_major_ticks() self.create_y_major_ticks_labels() return self.id
[docs] def create_x_axis(self, origin=None): """Draw the positive and negative x-axis arrows on the canvas.""" if origin is None: origin = self.reference_point x_lims = self.axes_limits[0] y_lims = self.axes_limits[1] if self.x_axis_at_bottom: origin = origin + Point(0, y_lims[0], basis=self.basis) with PointDefault(basis=self.basis): start = Point(0,0) end = Point(x_lims[1] * 1.05, 0) self.x_axis_positive = Arrow( start=start, end=end, **self._element_kwargs, ) self.x_axis_positive.create(self.canvas, origin) self.x_axis_positive.add_tag(f"group-{self.id}") self.x_axis_positive.add_tag("x-axis") with PointDefault(basis=self.basis): start = Point(0,0) end = Point(x_lims[0] * 1.2, 0) self.x_axis_negative = Arrow( start=start, end=end, **self._element_kwargs, ) self.x_axis_negative.create(self.canvas, origin) self.x_axis_negative.add_tag(f"group-{self.id}") self.x_axis_negative.add_tag("x-axis")
[docs] def create_y_axis(self, origin=None): """Draw the positive and negative y-axis on the canvas.""" if origin is None: origin = self.reference_point y_lims = self.axes_limits[1] with PointDefault(basis=self.basis): start = Point(0,0) end = Point(0, y_lims[1] * 1.2) self.y_axis_positive = Arrow( start=start, end=end, **self._element_kwargs, ) self.y_axis_positive.create(self.canvas, origin) self.y_axis_positive.add_tag(f"group-{self.id}") self.y_axis_positive.add_tag("y-axis") with PointDefault(basis=self.basis): start = Point(0,0) end = Point(0, y_lims[0]) if self.x_axis_at_bottom else Point(0, y_lims[0] * 1.2) self.y_axis_negative = Line( points=(start,end), **self._element_kwargs, ) self.y_axis_negative.create(self.canvas, origin) self.y_axis_negative.add_tag(f"group-{self.id}") self.y_axis_negative.add_tag("y-axis")
[docs] def x_major_ticks(self): """Return a list of nicely spaced major tick values for the x-axis.""" x_lims = self.axes_limits[0] ticks = get_nice_ticks(x_lims[0], x_lims[1], num_ticks=self.nx_major) return [ tick for tick in ticks if tick >= x_lims[0] and tick <= x_lims[1] ]
[docs] def y_major_ticks(self): """Return a list of nicely spaced major tick values for the y-axis.""" x_lims = self.axes_limits[1] ticks = get_nice_ticks(x_lims[0], x_lims[1], num_ticks=self.ny_major) return [ tick for tick in ticks if tick >= x_lims[0] and tick <= x_lims[1] ]
[docs] def create_x_major_ticks(self, origin=None): """Draw major tick marks along the x-axis.""" if origin is None: origin = self.reference_point width = self._element_kwargs.get("width", 1) y_lims = self.axes_limits[1] if self.x_axis_at_bottom: origin = origin + Point(0, y_lims[0], basis=self.basis) # In x, we use the local scale, but in y we use canvas units tick_basis = Basis(e0=self.basis.e0, e1=self.basis.e1.normalized()) for tick_value in self.x_major_ticks(): tick_start= Point(tick_value, 0, basis=tick_basis) tick_end = Point(tick_value, self.major_length * width, basis=tick_basis) tick = Line(points=(tick_start, tick_end), **self._element_kwargs) tick.create(self.canvas, position=origin) tick.add_tag(f"group-{self.id}") tick.add_tag("x-axis") tick.add_tag("tick")
[docs] def create_x_major_ticks_labels(self, origin=None): """Draw numeric labels below each x-axis major tick.""" if origin is None: origin = self.reference_point width = self._element_kwargs.get("width", 1) y_lims = self.axes_limits[1] if self.x_axis_at_bottom: origin = origin + Point(0, y_lims[0], basis=self.basis) # In x, we use the local scale, but in y we use canvas units tick_basis = Basis(e0=self.basis.e0, e1=self.basis.e1.normalized()) for tick_value in self.x_major_ticks(): tick_start = Point(tick_value, 0, basis=tick_basis) tick_start = tick_start + Vector(0, self.major_length*width*self.tick_value_offset, tick_basis) value = CanvasLabel( text=self.x_format.format(tick_value), font_size=self.tick_text_size * width, anchor="center", ) value.create( self.canvas, position=origin + tick_start, ) value.add_tag(f"group-{self.id}") value.add_tag("x-axis") value.add_tag("tick-label")
[docs] def create_y_major_ticks(self, origin=None): """Draw major tick marks along the y-axis.""" if origin is None: origin = self.reference_point width = self._element_kwargs.get("width", 1) # In x, we use the local scale, but in y we use canvas units tick_basis = Basis(e0=self.basis.e0.normalized(), e1=self.basis.e1) for tick_value in self.y_major_ticks(): tick_start= Point(0, tick_value, basis=tick_basis) tick_end = Point(self.major_length * width, tick_value, basis=tick_basis) tick = Line(points=(tick_start, tick_end), **self._element_kwargs) tick.create(self.canvas, position=origin) tick.add_tag(f"group-{self.id}") tick.add_tag("y-axis") tick.add_tag("tick")
[docs] def create_y_major_ticks_labels(self, origin=None): """Draw numeric labels beside each y-axis major tick.""" if origin is None: origin = self.reference_point width = self._element_kwargs.get("width", 1) # In x, we use the local scale, but in y we use canvas units tick_basis = Basis(e0=self.basis.e0.normalized(), e1=self.basis.e1) for tick_value in self.y_major_ticks(): tick_start = Point(0,tick_value, basis=tick_basis) tick_start = tick_start + Vector(self.major_length*width*self.tick_value_offset*(-1), 0, tick_basis) value = CanvasLabel( text=self.y_format.format(tick_value), font_size=self.tick_text_size * width, anchor="center", ) value.create( self.canvas, position=origin + tick_start, ) value.add_tag(f"group-{self.id}") value.add_tag("y-axis") value.add_tag("tick-label")
[docs] def place(self, element, position): """Place an element at a data-coordinate position within this coordinate system.""" position.basis = self.basis self.canvas.place(element, self.reference_point + position)
[docs] def nice_number(x, round_to_nearest=True): """Rounds or ceilings the number `x` to a 'nice' value, which is one of 1, 2, or 5 times a power of 10.""" exp = floor(log10(x)) # Exponent of the range frac = x / 10**exp # Fractional part of the range if round_to_nearest: # Round to the nearest 'nice' value if frac < 1.5: nice = 1 elif frac < 3: nice = 2 elif frac < 7: nice = 5 else: nice = 10 else: # Ceiling to the next 'nice' value if frac <= 1: nice = 1 elif frac <= 2: nice = 2 elif frac <= 5: nice = 5 else: nice = 10 return nice * 10**exp
[docs] def get_nice_ticks(x_min, x_max, num_ticks=5): """Generate 'nice' tick marks for the axis spanning from x_min to x_max.""" range_x = x_max - x_min if range_x == 0: return [x_min] # Single point case # Get an approximate step size step_size_approx = range_x / (num_ticks - 1) # Round the step size to a 'nice' value step_size = nice_number(step_size_approx) # Find the lower bound for the ticks (multiple of step_size below x_min) tick_min = floor(x_min / step_size) * step_size # Find the upper bound for the ticks (multiple of step_size above x_max) tick_max = ceil(x_max / step_size) * step_size # Generate the tick values from tick_min to tick_max with step_size ticks = [] tick = tick_min while tick <= tick_max: ticks.append(tick) tick += step_size return ticks