From 38aa709ce21dd158539330beedaa4d153809d7af Mon Sep 17 00:00:00 2001 From: yumoqing Date: Tue, 28 Dec 2021 16:33:07 +0800 Subject: [PATCH] bugfix --- __pycache__/version.cpython-39.pyc | Bin 0 -> 134 bytes build/lib/kivycharts/__init__.py | 0 build/lib/kivycharts/bar.py | 85 ++ build/lib/kivycharts/chart.py | 79 ++ build/lib/kivycharts/geometry.py | 81 ++ build/lib/kivycharts/graph/__init__.py | 6 + build/lib/kivycharts/graph/_version.py | 1 + build/lib/kivycharts/graph/arc.py | 13 + build/lib/kivycharts/graph/base.py | 988 +++++++++++++++++++++ build/lib/kivycharts/graph/beziercurve.py | 14 + build/lib/kivycharts/graph/circle.py | 10 + build/lib/kivycharts/graph/compoundpath.py | 7 + build/lib/kivycharts/graph/displayable.py | 5 + build/lib/kivycharts/graph/droplet.py | 11 + build/lib/kivycharts/graph/ellipse.py | 11 + build/lib/kivycharts/graph/heart.py | 11 + build/lib/kivycharts/graph/image.py | 12 + build/lib/kivycharts/graph/line.py | 12 + build/lib/kivycharts/graph/lsogon.py | 10 + build/lib/kivycharts/graph/path.py | 14 + build/lib/kivycharts/graph/plot.py | 795 +++++++++++++++++ build/lib/kivycharts/graph/polygon.py | 10 + build/lib/kivycharts/graph/polyline.py | 10 + build/lib/kivycharts/graph/rectangle.py | 12 + build/lib/kivycharts/graph/rose.py | 12 + build/lib/kivycharts/graph/sector.py | 14 + build/lib/kivycharts/graph/star.py | 13 + build/lib/kivycharts/graph/text.py | 9 + build/lib/kivycharts/graph/touchoid.py | 12 + build/lib/kivycharts/line.py | 130 +++ build/lib/kivycharts/pie.py | 66 ++ dist/kivycharts-0.0.5-py3.9.egg | Bin 0 -> 71528 bytes kivycharts.egg-info/PKG-INFO | 14 + kivycharts.egg-info/SOURCES.txt | 37 + kivycharts.egg-info/dependency_links.txt | 1 + kivycharts.egg-info/requires.txt | 10 + kivycharts.egg-info/top_level.txt | 1 + kivycharts/.line.py.swp | Bin 0 -> 12288 bytes kivycharts/graph/.base.py.swp | Bin 0 -> 57344 bytes kivycharts/graph/base.py | 988 +++++++++++++++++++++ kivycharts/graph/plot.py | 795 +++++++++++++++++ kivycharts/line.py | 130 +++ test/tgraph.py | 147 +++ 43 files changed, 4576 insertions(+) create mode 100644 __pycache__/version.cpython-39.pyc create mode 100644 build/lib/kivycharts/__init__.py create mode 100644 build/lib/kivycharts/bar.py create mode 100644 build/lib/kivycharts/chart.py create mode 100644 build/lib/kivycharts/geometry.py create mode 100644 build/lib/kivycharts/graph/__init__.py create mode 100644 build/lib/kivycharts/graph/_version.py create mode 100644 build/lib/kivycharts/graph/arc.py create mode 100644 build/lib/kivycharts/graph/base.py create mode 100644 build/lib/kivycharts/graph/beziercurve.py create mode 100644 build/lib/kivycharts/graph/circle.py create mode 100644 build/lib/kivycharts/graph/compoundpath.py create mode 100644 build/lib/kivycharts/graph/displayable.py create mode 100644 build/lib/kivycharts/graph/droplet.py create mode 100644 build/lib/kivycharts/graph/ellipse.py create mode 100644 build/lib/kivycharts/graph/heart.py create mode 100644 build/lib/kivycharts/graph/image.py create mode 100644 build/lib/kivycharts/graph/line.py create mode 100644 build/lib/kivycharts/graph/lsogon.py create mode 100644 build/lib/kivycharts/graph/path.py create mode 100644 build/lib/kivycharts/graph/plot.py create mode 100644 build/lib/kivycharts/graph/polygon.py create mode 100644 build/lib/kivycharts/graph/polyline.py create mode 100644 build/lib/kivycharts/graph/rectangle.py create mode 100644 build/lib/kivycharts/graph/rose.py create mode 100644 build/lib/kivycharts/graph/sector.py create mode 100644 build/lib/kivycharts/graph/star.py create mode 100644 build/lib/kivycharts/graph/text.py create mode 100644 build/lib/kivycharts/graph/touchoid.py create mode 100644 build/lib/kivycharts/line.py create mode 100644 build/lib/kivycharts/pie.py create mode 100644 dist/kivycharts-0.0.5-py3.9.egg create mode 100644 kivycharts.egg-info/PKG-INFO create mode 100644 kivycharts.egg-info/SOURCES.txt create mode 100644 kivycharts.egg-info/dependency_links.txt create mode 100644 kivycharts.egg-info/requires.txt create mode 100644 kivycharts.egg-info/top_level.txt create mode 100644 kivycharts/.line.py.swp create mode 100644 kivycharts/graph/.base.py.swp create mode 100644 kivycharts/graph/base.py create mode 100644 kivycharts/graph/plot.py create mode 100644 kivycharts/line.py create mode 100644 test/tgraph.py diff --git a/__pycache__/version.cpython-39.pyc b/__pycache__/version.cpython-39.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0d7f8e6cd4e163f365f1d605bceb918507226da3 GIT binary patch literal 134 zcmYe~<>g`kf>JlJL}4KP7{oyaj6jY95EpX*i4=w?h7`tN22G|aRs%f)JySnT##`)V zsYS(^`FSfDikN_Ez{D>p7ps_p%9!lTvdZL)#G;ZIs3N_B%3B;Zx%nxjIjMFavp)kd F0|2}?9Krwq literal 0 HcmV?d00001 diff --git a/build/lib/kivycharts/__init__.py b/build/lib/kivycharts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/build/lib/kivycharts/bar.py b/build/lib/kivycharts/bar.py new file mode 100644 index 0000000..01f1b33 --- /dev/null +++ b/build/lib/kivycharts/bar.py @@ -0,0 +1,85 @@ +from kivy.uix.widget import Widget +from kivy.graphics import Color, Rectangle +from kivyblocks.colorcalc import * +from kivyblocks.charts.chart import Chart,ChartPart + +class BarPart(ChartPart): + def __init__(self,widget,pos,size,color,data_offset): + self.widget = widget + self.pos = pos + self.size = size + self.color = color + self.data_offset = data_offset + + def collide_point(self,x,y): + minx = self.widget.pos[0] + self.pos[0] + maxx = minx + self.size[0] + miny = self.widget.pos[1] + self.pos[1] + maxy = miny + self.size[1] + if minx <= x and x <= maxx and \ + miny <= y and y <= maxy: + return True + return False + + def draw(self): + with self.widget.canvas: + Color(*self.color) + Rectangle(pos=(self.widget.pos[0] + self.pos[0], + self.widget.pos[1] + self.pos[1]), + size = self.size) + + def mark(self): + rcolor = reverseColor(self.color) + rcolor.append(0.8) + with self.widget.canvas.after: + Color(*reverseColor(self.color)) + Rectangle(pos=(self.widget.pos[0] + self.pos[0], + self.widget.pos[1] + self.pos[1]), + size = self.size) + +class Bar(Chart): + """ + a BAR class + """ + def __init__(self,**options): + """ + options={ + width, + height, + title, + keyField, + valueField, + color1: + color2: + data=[ + { + name, + value, + },{ + } + ] + } + """ + self.data = None + super().__init__(**options) + + def data2part(self): + data = self.data + kvs = [ [i[self.options['keyField']],i[self.options['valueField']]] for i in data ] + m = max([i[1] for i in kvs ]) + cnt = len(kvs) + points = divide([0,0],[self.width,0],cnt) + color1='8833ee' + color2='ed8234' + colors = divideColor(color1,color2,cnt-1) + for i in range(cnt): + h = self.height * kvs[i][1] / m + c = colors[i] + part = BarPart(self, + points[i], + (points[i+1][0] - points[i][0],h), + colors[i], + i + ) + self.chartparts.append(part) + diff --git a/build/lib/kivycharts/chart.py b/build/lib/kivycharts/chart.py new file mode 100644 index 0000000..f7aef87 --- /dev/null +++ b/build/lib/kivycharts/chart.py @@ -0,0 +1,79 @@ +from kivy.app import App +from kivy.graphics import Color, Rectangle +from kivy.uix.widget import Widget +from kivyblocks.colorcalc import toArrayColor + +class ChartPart(object): + def __init__(self,pos,width,height): + self.pos = pos + self.width = width + self.height = height + + def collide_point(self,x,y): + raise NotImplementedError + + def draw(self): + raise NotImplementedError + + def mark(self): + raise NotImplementedError + +class Chart(Widget): + def __init__(self,**options): + super().__init__() + self.options = options + self.bg_color = toArrayColor(self.options.get('bg_color',[0.3,0.3,0.3,1])) + self.markpart = None + self.bind(size=self.onSize,pos=self.onSize) + self.register_event_type("on_press") + + def unmark(self): + self.canvas.after.clear() + + def getData(self): + url = self.options.get('url') + if url: + hc = App.get_running_app().hc + params = self.options.get('params',{}) + d = hc.get(url,parms=param) + self.data = d.get('data',[]) + else: + self.data = self.options.get('data',[]) + self.chartparts = [] + + def on_touch_down(self,touch): + if touch.is_mouse_scrolling: + return False + if not self.collide_point(touch.x,touch.y): + return False + self.markpart = None + self.unmark() + for part in self.chartparts: + if part.collide_point(touch.x,touch.y): + self.markPart(part) + self.dispatch('on_press',self,self.data[part.data_offset]) + return super(Chart, self).on_touch_down(touch) + + def on_press(self,o,data): + print('data=',data) + + def draw(self): + with self.canvas.before: + Color(*self.bg_color) + Rectangle(pos=self.pos,size=self.size) + + self.canvas.clear() + for part in self.chartparts: + part.draw() + + def markPart(self,part): + self.markpart = part + part.mark() + + def onSize(self,o,v): + self.build() + + def build(self): + self.getData() + self.data2part() + self.draw() diff --git a/build/lib/kivycharts/geometry.py b/build/lib/kivycharts/geometry.py new file mode 100644 index 0000000..166d640 --- /dev/null +++ b/build/lib/kivycharts/geometry.py @@ -0,0 +1,81 @@ +import math +import numpy as np + +class Point(object): + def __init__(self, x=0, y=0): + super().__init__() + self.x = x + self.y = y + +class Line(object): # 直线由两个点组成 + def __init__(self, p1, p2): + if isinstance(p1,list) or isinstance(p1,tuple): + self.p1 = Point(*p1) + else: + self.p1 = p1 + if isinstance(p2,list) or isinstance(p2,tuple): + self.p2 = Point(*p2) + else: + self.p2 = p2 + + def vector(self): + return self.p1.x - self.p2.x,self.p1.y - self.p2.y + + def lenght(self): + return math.sqrt(pow((self.p1.x - self.p2.x), 2) + + pow((self.p1.y - self.p2.y), 2)) + + def get_cross_angle(self, l): + # 向量a + arr_a = np.array(self.vector()) + # 向量b + arr_b = np.array(l.vector()) + cos_value = float(arr_a.dot(arr_b)) / (np.sqrt(arr_a.dot(arr_a)) \ + * np.sqrt(arr_b.dot(arr_b))) + # 注意转成浮点数运算 + return np.arccos(cos_value) * (180 / np.pi) + # 两个向量的夹角的角度, + # 余弦值:cos_value, np.cos(para), + # 其中para是弧度,不是角度 + +class EllipseUtils(object): + def __init__(self,pos,size): + self.pos = Point(pos) + self.size = size + self.slides = [] + super().__init__() + + def split(self,data,colors): + self.slides = [] + kvs = [ [i[self.options['keyField']],i[self.options['valueField']]] for i in data ] + total = sum([i[1] for i in kvs ]) + start_degree = 0 + cnt = len(kvs) + for i in range(cnt): + degree = start_degree + 360 * kvs[i][1] / total + self.slides.append((degress,colors[i])) + start_degree = degree + + def isInside(self,a,b,x,y): + if a>b: + return x**x / a**a + y**y/b**b <= 1 + return x**x / b**b + y**y / a**a <= 1 + + def collide_point(self,x,y): + a = float(self.size[0] / 2) + b = float(self.size[1] / 2) + x = x - self.pos[0] + y = y - self.pos[1] + if not self.isInside(a,b,x,y): + return -1 + start_degress = 0 + l = Line((a,b),(a,b*2)) + l1 = Line((a,b),(x,y)) + degress = l.get_cross_angle(l1) + if x < a: + degress = 360 - degress + for i in range(len(self.slides)): + if start_degress <= degress and degress < self.slides[i][0]: + return i + start_degress += self.slides[i][0] + return -1 diff --git a/build/lib/kivycharts/graph/__init__.py b/build/lib/kivycharts/graph/__init__.py new file mode 100644 index 0000000..f6b27fc --- /dev/null +++ b/build/lib/kivycharts/graph/__init__.py @@ -0,0 +1,6 @@ +from .base import Graph +from .plot import Plot, MeshLinePlot, MeshStemPlot, LinePlot, \ + SmoothLinePlot, ContourPlot, ScatterPlot, PointPlot, \ + BarPlot, HBar, VBar + + diff --git a/build/lib/kivycharts/graph/_version.py b/build/lib/kivycharts/graph/_version.py new file mode 100644 index 0000000..df9144c --- /dev/null +++ b/build/lib/kivycharts/graph/_version.py @@ -0,0 +1 @@ +__version__ = '0.1.1' diff --git a/build/lib/kivycharts/graph/arc.py b/build/lib/kivycharts/graph/arc.py new file mode 100644 index 0000000..715d9ec --- /dev/null +++ b/build/lib/kivycharts/graph/arc.py @@ -0,0 +1,13 @@ +# arc.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.r number 0 半径。 +opts.shape.startAngle number 0 起始弧度。 +opts.shape.endAngle number Math.PI * 2 终止弧度。 +opts.shape.clockwise boolean true 顺时针方向。 +""" + diff --git a/build/lib/kivycharts/graph/base.py b/build/lib/kivycharts/graph/base.py new file mode 100644 index 0000000..edb6d48 --- /dev/null +++ b/build/lib/kivycharts/graph/base.py @@ -0,0 +1,988 @@ +''' +Graph +====== + +The :class:`Graph` widget is a widget for displaying plots. It supports +drawing multiple plot with different colors on the Graph. It also supports +axes titles, ticks, labeled ticks, grids and a log or linear representation on +both the x and y axis, independently. + +To display a plot. First create a graph which will function as a "canvas" for +the plots. Then create plot objects e.g. MeshLinePlot and add them to the +graph. + +To create a graph with x-axis between 0-100, y-axis between -1 to 1, x and y +labels of and X and Y, respectively, x major and minor ticks every 25, 5 units, +respectively, y major ticks every 1 units, full x and y grids and with +a red line plot containing a sin wave on this range:: + + from kivy_garden.graph import Graph, MeshLinePlot + graph = Graph(xlabel='X', ylabel='Y', x_ticks_minor=5, + x_ticks_major=25, y_ticks_major=1, + y_grid_label=True, x_grid_label=True, padding=5, + x_grid=True, y_grid=True, xmin=-0, xmax=100, ymin=-1, ymax=1) + plot = MeshLinePlot(color=[1, 0, 0, 1]) + plot.points = [(x, sin(x / 10.)) for x in range(0, 101)] + graph.add_plot(plot) + +The MeshLinePlot plot is a particular plot which draws a set of points using +a mesh object. The points are given as a list of tuples, with each tuple +being a (x, y) coordinate in the graph's units. + +You can create different types of plots other than MeshLinePlot by inheriting +from the Plot class and implementing the required functions. The Graph object +provides a "canvas" to which a Plot's instructions are added. The plot object +is responsible for updating these instructions to show within the bounding +box of the graph the proper plot. The Graph notifies the Plot when it needs +to be redrawn due to changes. See the MeshLinePlot class for how it is done. + +The current availables plots are: + + * `MeshStemPlot` + * `MeshLinePlot` + * `SmoothLinePlot` - require Kivy 1.8.1 + +.. note:: + + The graph uses a stencil view to clip the plots to the graph display area. + As with the stencil graphics instructions, you cannot stack more than 8 + stencil-aware widgets. + +''' + +from kivy.uix.widget import Widget +from kivy.uix.label import Label +from kivy.uix.stencilview import StencilView +from kivy.properties import NumericProperty, BooleanProperty,\ + BoundedNumericProperty, StringProperty, ListProperty, ObjectProperty,\ + DictProperty, AliasProperty +from kivy.clock import Clock +from kivy.graphics import Mesh, Color, Rectangle, Point +from kivy.graphics import Fbo +from kivy.graphics.texture import Texture +from kivy.event import EventDispatcher +from kivy.lang import Builder +from kivy.logger import Logger +from kivy import metrics +from math import log10, floor, ceil +from decimal import Decimal +from itertools import chain +try: + import numpy as np +except ImportError as e: + np = None + +from ._version import __version__ + + +def identity(x): + return x + + +def exp10(x): + return 10 ** x + + +Builder.load_string(""" +: + canvas.before: + PushMatrix + Rotate: + angle: root.angle + axis: 0, 0, 1 + origin: root.center + canvas.after: + PopMatrix +""") + + +class GraphRotatedLabel(Label): + angle = NumericProperty(0) + + +class Axis(EventDispatcher): + pass + + +class XAxis(Axis): + pass + + +class YAxis(Axis): + pass + + +class Graph(Widget): + '''Graph class, see module documentation for more information. + ''' + + # triggers a full reload of graphics + _trigger = ObjectProperty(None) + # triggers only a repositioning of objects due to size/pos updates + _trigger_size = ObjectProperty(None) + # triggers only a update of colors, e.g. tick_color + _trigger_color = ObjectProperty(None) + # holds widget with the x-axis label + _xlabel = ObjectProperty(None) + # holds widget with the y-axis label + _ylabel = ObjectProperty(None) + # holds all the x-axis tick mark labels + _x_grid_label = ListProperty([]) + # holds all the y-axis tick mark labels + _y_grid_label = ListProperty([]) + # the mesh drawing all the ticks/grids + _mesh_ticks = ObjectProperty(None) + # the mesh which draws the surrounding rectangle + _mesh_rect = ObjectProperty(None) + # a list of locations of major and minor ticks. The values are not + # but is in the axis min - max range + _ticks_majorx = ListProperty([]) + _ticks_minorx = ListProperty([]) + _ticks_majory = ListProperty([]) + _ticks_minory = ListProperty([]) + + tick_color = ListProperty([.25, .25, .25, 1]) + '''Color of the grid/ticks, default to 1/4. grey. + ''' + + background_color = ListProperty([0, 0, 0, 0]) + '''Color of the background, defaults to transparent + ''' + + border_color = ListProperty([1, 1, 1, 1]) + '''Color of the border, defaults to white + ''' + + x_grid_texts = ListProperty(None) + label_options = DictProperty() + '''Label options that will be passed to `:class:`kivy.uix.Label`. + ''' + + _with_stencilbuffer = BooleanProperty(True) + '''Whether :class:`Graph`'s FBO should use FrameBuffer (True) or not + (False). + + .. warning:: This property is internal and so should be used with care. + It can break some other graphic instructions used by the :class:`Graph`, + for example you can have problems when drawing :class:`SmoothLinePlot` + plots, so use it only when you know what exactly you are doing. + + :data:`_with_stencilbuffer` is a :class:`~kivy.properties.BooleanProperty`, + defaults to True. + ''' + + def __init__(self, **kwargs): + super(Graph, self).__init__(**kwargs) + + with self.canvas: + self._fbo = Fbo( + size=self.size, with_stencilbuffer=self._with_stencilbuffer) + + with self._fbo: + self._background_color = Color(*self.background_color) + self._background_rect = Rectangle(size=self.size) + self._mesh_ticks_color = Color(*self.tick_color) + self._mesh_ticks = Mesh(mode='lines') + self._mesh_rect_color = Color(*self.border_color) + self._mesh_rect = Mesh(mode='line_strip') + + with self.canvas: + Color(1, 1, 1) + self._fbo_rect = Rectangle( + size=self.size, texture=self._fbo.texture) + + mesh = self._mesh_rect + mesh.vertices = [0] * (5 * 4) + mesh.indices = range(5) + + self._plot_area = StencilView() + self.add_widget(self._plot_area) + + t = self._trigger = Clock.create_trigger(self._redraw_all) + ts = self._trigger_size = Clock.create_trigger(self._redraw_size) + tc = self._trigger_color = Clock.create_trigger(self._update_colors) + + self.bind(center=ts, padding=ts, precision=ts, plots=ts, x_grid=ts, + y_grid=ts, draw_border=ts) + self.bind(xmin=t, xmax=t, xlog=t, x_ticks_major=t, x_ticks_minor=t, + xlabel=t, x_grid_label=t, ymin=t, ymax=t, ylog=t, + y_ticks_major=t, y_ticks_minor=t, ylabel=t, y_grid_label=t, + font_size=t, label_options=t, x_ticks_angle=t) + self.bind(tick_color=tc, background_color=tc, border_color=tc) + self._trigger() + + def add_widget(self, widget): + if widget is self._plot_area: + canvas = self.canvas + self.canvas = self._fbo + super(Graph, self).add_widget(widget) + if widget is self._plot_area: + self.canvas = canvas + + def remove_widget(self, widget): + if widget is self._plot_area: + canvas = self.canvas + self.canvas = self._fbo + super(Graph, self).remove_widget(widget) + if widget is self._plot_area: + self.canvas = canvas + + def _get_ticks(self, major, minor, log, s_min, s_max): + if major and s_max > s_min: + if log: + s_min = log10(s_min) + s_max = log10(s_max) + # count the decades in min - max. This is in actual decades, + # not logs. + n_decades = floor(s_max - s_min) + # for the fractional part of the last decade, we need to + # convert the log value, x, to 10**x but need to handle + # differently if the last incomplete decade has a decade + # boundary in it + if floor(s_min + n_decades) != floor(s_max): + n_decades += 1 - (10 ** (s_min + n_decades + 1) - 10 ** + s_max) / 10 ** floor(s_max + 1) + else: + n_decades += ((10 ** s_max - 10 ** (s_min + n_decades)) / + 10 ** floor(s_max + 1)) + # this might be larger than what is needed, but we delete + # excess later + n_ticks_major = n_decades / float(major) + n_ticks = int(floor(n_ticks_major * (minor if minor >= + 1. else 1.0))) + 2 + # in decade multiples, e.g. 0.1 of the decade, the distance + # between ticks + decade_dist = major / float(minor if minor else 1.0) + + points_minor = [0] * n_ticks + points_major = [0] * n_ticks + k = 0 # position in points major + k2 = 0 # position in points minor + # because each decade is missing 0.1 of the decade, if a tick + # falls in < min_pos skip it + min_pos = 0.1 - 0.00001 * decade_dist + s_min_low = floor(s_min) + # first real tick location. value is in fractions of decades + # from the start we have to use decimals here, otherwise + # floating point inaccuracies results in bad values + start_dec = ceil((10 ** Decimal(s_min - s_min_low - 1)) / + Decimal(decade_dist)) * decade_dist + count_min = (0 if not minor else + floor(start_dec / decade_dist) % minor) + start_dec += s_min_low + count = 0 # number of ticks we currently have passed start + while True: + # this is the current position in decade that we are. + # e.g. -0.9 means that we're at 0.1 of the 10**ceil(-0.9) + # decade + pos_dec = start_dec + decade_dist * count + pos_dec_low = floor(pos_dec) + diff = pos_dec - pos_dec_low + zero = abs(diff) < 0.001 * decade_dist + if zero: + # the same value as pos_dec but in log scale + pos_log = pos_dec_low + else: + pos_log = log10((pos_dec - pos_dec_low + ) * 10 ** ceil(pos_dec)) + if pos_log > s_max: + break + count += 1 + if zero or diff >= min_pos: + if minor and not count_min % minor: + points_major[k] = pos_log + k += 1 + else: + points_minor[k2] = pos_log + k2 += 1 + count_min += 1 + else: + # distance between each tick + tick_dist = major / float(minor if minor else 1.0) + n_ticks = int(floor((s_max - s_min) / tick_dist) + 1) + points_major = [0] * int(floor((s_max - s_min) / float(major)) + + 1) + points_minor = [0] * (n_ticks - len(points_major) + 1) + k = 0 # position in points major + k2 = 0 # position in points minor + for m in range(0, n_ticks): + if minor and m % minor: + if k2 >= len(points_minor): + continue + points_minor[k2] = m * tick_dist + s_min + k2 += 1 + else: + points_major[k] = m * tick_dist + s_min + k += 1 + del points_major[k:] + del points_minor[k2:] + else: + points_major = [] + points_minor = [] + return points_major, points_minor + + def _update_labels(self): + xlabel = self._xlabel + ylabel = self._ylabel + x = self.x + y = self.y + width = self.width + height = self.height + padding = self.padding + x_next = padding + x + y_next = padding + y + xextent = width + x + yextent = height + y + ymin = self.ymin + ymax = self.ymax + xmin = self.xmin + precision = self.precision + x_overlap = False + y_overlap = False + # set up x and y axis labels + if xlabel: + xlabel.text = self.xlabel + xlabel.texture_update() + xlabel.size = xlabel.texture_size + xlabel.pos = int( + x + width / 2. - xlabel.width / 2.), int(padding + y) + y_next += padding + xlabel.height + if ylabel: + ylabel.text = self.ylabel + ylabel.texture_update() + ylabel.size = ylabel.texture_size + ylabel.x = padding + x - (ylabel.width / 2. - ylabel.height / 2.) + x_next += padding + ylabel.height + xpoints = self._ticks_majorx + xlabels = self._x_grid_label + xlabel_grid = self.x_grid_label + ylabel_grid = self.y_grid_label + ypoints = self._ticks_majory + ylabels = self._y_grid_label + # now x and y tick mark labels + if len(ylabels) and ylabel_grid: + # horizontal size of the largest tick label, to have enough room + funcexp = exp10 if self.ylog else identity + funclog = log10 if self.ylog else identity + ylabels[0].text = precision % funcexp(ypoints[0]) + ylabels[0].texture_update() + y1 = ylabels[0].texture_size + y_start = y_next + (padding + y1[1] if len(xlabels) and xlabel_grid + else 0) + \ + (padding + y1[1] if not y_next else 0) + yextent = y + height - padding - y1[1] / 2. + + ymin = funclog(ymin) + ratio = (yextent - y_start) / float(funclog(ymax) - ymin) + y_start -= y1[1] / 2. + y1 = y1[0] + for k in range(len(ylabels)): + ylabels[k].text = precision % funcexp(ypoints[k]) + ylabels[k].texture_update() + ylabels[k].size = ylabels[k].texture_size + y1 = max(y1, ylabels[k].texture_size[0]) + ylabels[k].pos = ( + int(x_next), + int(y_start + (ypoints[k] - ymin) * ratio)) + if len(ylabels) > 1 and ylabels[0].top > ylabels[1].y: + y_overlap = True + else: + x_next += y1 + padding + if len(xlabels) and xlabel_grid: + funcexp = exp10 if self.xlog else identity + funclog = log10 if self.xlog else identity + # find the distance from the end that'll fit the last tick label + if self.x_grid_texts: + xlabels[0].text = self.x_grid_texts[int(xpoints[-1])] + else: + xlabels[0].text = precision % funcexp(xpoints[-1]) + xlabels[0].texture_update() + xextent = x + width - xlabels[0].texture_size[0] / 2. - padding + # find the distance from the start that'll fit the first tick label + if not x_next: + if self.x_grid_texts: + xlabels[0].text = self.x_grid_texts[int(xpoints[0])] + else: + xlabels[0].text = precision % funcexp(xpoints[0]) + xlabels[0].texture_update() + x_next = padding + xlabels[0].texture_size[0] / 2. + xmin = funclog(xmin) + ratio = (xextent - x_next) / float(funclog(self.xmax) - xmin) + right = -1 + for k in range(len(xlabels)): + if self.x_grid_texts: + xlabels[k].text = self.x_grid_texts[int(xpoints[k])] + else: + xlabels[k].text = precision % funcexp(xpoints[k]) + # update the size so we can center the labels on ticks + xlabels[k].texture_update() + xlabels[k].size = xlabels[k].texture_size + half_ts = xlabels[k].texture_size[0] / 2. + xlabels[k].pos = ( + int(x_next + (xpoints[k] - xmin) * ratio - half_ts), + int(y_next)) + if xlabels[k].x < right: + x_overlap = True + break + right = xlabels[k].right + if not x_overlap: + y_next += padding + xlabels[0].texture_size[1] + # now re-center the x and y axis labels + if xlabel: + xlabel.x = int( + x_next + (xextent - x_next) / 2. - xlabel.width / 2.) + if ylabel: + ylabel.y = int( + y_next + (yextent - y_next) / 2. - ylabel.height / 2.) + ylabel.angle = 90 + if x_overlap: + for k in range(len(xlabels)): + xlabels[k].text = '' + if y_overlap: + for k in range(len(ylabels)): + ylabels[k].text = '' + return x_next - x, y_next - y, xextent - x, yextent - y + + def _update_ticks(self, size): + # re-compute the positions of the bounding rectangle + mesh = self._mesh_rect + vert = mesh.vertices + if self.draw_border: + s0, s1, s2, s3 = size + vert[0] = s0 + vert[1] = s1 + vert[4] = s2 + vert[5] = s1 + vert[8] = s2 + vert[9] = s3 + vert[12] = s0 + vert[13] = s3 + vert[16] = s0 + vert[17] = s1 + else: + vert[0:18] = [0 for k in range(18)] + mesh.vertices = vert + # re-compute the positions of the x/y axis ticks + mesh = self._mesh_ticks + vert = mesh.vertices + start = 0 + xpoints = self._ticks_majorx + ypoints = self._ticks_majory + xpoints2 = self._ticks_minorx + ypoints2 = self._ticks_minory + ylog = self.ylog + xlog = self.xlog + xmin = self.xmin + xmax = self.xmax + if xlog: + xmin = log10(xmin) + xmax = log10(xmax) + ymin = self.ymin + ymax = self.ymax + if ylog: + ymin = log10(ymin) + ymax = log10(ymax) + if len(xpoints): + top = size[3] if self.x_grid else metrics.dp(12) + size[1] + ratio = (size[2] - size[0]) / float(xmax - xmin) + for k in range(start, len(xpoints) + start): + vert[k * 8] = size[0] + (xpoints[k - start] - xmin) * ratio + vert[k * 8 + 1] = size[1] + vert[k * 8 + 4] = vert[k * 8] + vert[k * 8 + 5] = top + start += len(xpoints) + if len(xpoints2): + top = metrics.dp(8) + size[1] + ratio = (size[2] - size[0]) / float(xmax - xmin) + for k in range(start, len(xpoints2) + start): + vert[k * 8] = size[0] + (xpoints2[k - start] - xmin) * ratio + vert[k * 8 + 1] = size[1] + vert[k * 8 + 4] = vert[k * 8] + vert[k * 8 + 5] = top + start += len(xpoints2) + if len(ypoints): + top = size[2] if self.y_grid else metrics.dp(12) + size[0] + ratio = (size[3] - size[1]) / float(ymax - ymin) + for k in range(start, len(ypoints) + start): + vert[k * 8 + 1] = size[1] + (ypoints[k - start] - ymin) * ratio + vert[k * 8 + 5] = vert[k * 8 + 1] + vert[k * 8] = size[0] + vert[k * 8 + 4] = top + start += len(ypoints) + if len(ypoints2): + top = metrics.dp(8) + size[0] + ratio = (size[3] - size[1]) / float(ymax - ymin) + for k in range(start, len(ypoints2) + start): + vert[k * 8 + 1] = size[1] + ( + ypoints2[k - start] - ymin) * ratio + vert[k * 8 + 5] = vert[k * 8 + 1] + vert[k * 8] = size[0] + vert[k * 8 + 4] = top + mesh.vertices = vert + + x_axis = ListProperty([None]) + y_axis = ListProperty([None]) + + def get_x_axis(self, axis=0): + if axis == 0: + return self.xlog, self.xmin, self.xmax + info = self.x_axis[axis] + return info["log"], info["min"], info["max"] + + def get_y_axis(self, axis=0): + if axis == 0: + return self.ylog, self.ymin, self.ymax + info = self.y_axis[axis] + return info["log"], info["min"], info["max"] + + def add_x_axis(self, xmin, xmax, xlog=False): + data = { + "log": xlog, + "min": xmin, + "max": xmax + } + self.x_axis.append(data) + return data + + def add_y_axis(self, ymin, ymax, ylog=False): + data = { + "log": ylog, + "min": ymin, + "max": ymax + } + self.y_axis.append(data) + return data + + def _update_plots(self, size): + for plot in self.plots: + xlog, xmin, xmax = self.get_x_axis(plot.x_axis) + ylog, ymin, ymax = self.get_y_axis(plot.y_axis) + plot._update(xlog, xmin, xmax, ylog, ymin, ymax, size) + + def _update_colors(self, *args): + self._mesh_ticks_color.rgba = tuple(self.tick_color) + self._background_color.rgba = tuple(self.background_color) + self._mesh_rect_color.rgba = tuple(self.border_color) + + def _redraw_all(self, *args): + # add/remove all the required labels + xpoints_major, xpoints_minor = self._redraw_x(*args) + ypoints_major, ypoints_minor = self._redraw_y(*args) + + mesh = self._mesh_ticks + n_points = (len(xpoints_major) + len(xpoints_minor) + + len(ypoints_major) + len(ypoints_minor)) + mesh.vertices = [0] * (n_points * 8) + mesh.indices = [k for k in range(n_points * 2)] + self._redraw_size() + + def _redraw_x(self, *args): + font_size = self.font_size + if self.xlabel: + xlabel = self._xlabel + if not xlabel: + xlabel = Label() + self.add_widget(xlabel) + self._xlabel = xlabel + + xlabel.font_size = font_size + for k, v in self.label_options.items(): + setattr(xlabel, k, v) + + else: + xlabel = self._xlabel + if xlabel: + self.remove_widget(xlabel) + self._xlabel = None + grids = self._x_grid_label + xpoints_major, xpoints_minor = self._get_ticks(self.x_ticks_major, + self.x_ticks_minor, + self.xlog, self.xmin, + self.xmax) + self._ticks_majorx = xpoints_major + self._ticks_minorx = xpoints_minor + + if not self.x_grid_label: + n_labels = 0 + else: + n_labels = len(xpoints_major) + + for k in range(n_labels, len(grids)): + self.remove_widget(grids[k]) + del grids[n_labels:] + + grid_len = len(grids) + grids.extend([None] * (n_labels - len(grids))) + for k in range(grid_len, n_labels): + grids[k] = GraphRotatedLabel( + font_size=font_size, angle=self.x_ticks_angle, + **self.label_options) + self.add_widget(grids[k]) + return xpoints_major, xpoints_minor + + def _redraw_y(self, *args): + font_size = self.font_size + if self.ylabel: + ylabel = self._ylabel + if not ylabel: + ylabel = GraphRotatedLabel() + self.add_widget(ylabel) + self._ylabel = ylabel + + ylabel.font_size = font_size + for k, v in self.label_options.items(): + setattr(ylabel, k, v) + else: + ylabel = self._ylabel + if ylabel: + self.remove_widget(ylabel) + self._ylabel = None + grids = self._y_grid_label + ypoints_major, ypoints_minor = self._get_ticks(self.y_ticks_major, + self.y_ticks_minor, + self.ylog, self.ymin, + self.ymax) + self._ticks_majory = ypoints_major + self._ticks_minory = ypoints_minor + + if not self.y_grid_label: + n_labels = 0 + else: + n_labels = len(ypoints_major) + + for k in range(n_labels, len(grids)): + self.remove_widget(grids[k]) + del grids[n_labels:] + + grid_len = len(grids) + grids.extend([None] * (n_labels - len(grids))) + for k in range(grid_len, n_labels): + grids[k] = Label(font_size=font_size, **self.label_options) + self.add_widget(grids[k]) + return ypoints_major, ypoints_minor + + def _redraw_size(self, *args): + # size a 4-tuple describing the bounding box in which we can draw + # graphs, it's (x0, y0, x1, y1), which correspond with the bottom left + # and top right corner locations, respectively + self._clear_buffer() + size = self._update_labels() + self.view_pos = self._plot_area.pos = (size[0], size[1]) + self.view_size = self._plot_area.size = ( + size[2] - size[0], size[3] - size[1]) + + if self.size[0] and self.size[1]: + self._fbo.size = self.size + else: + self._fbo.size = 1, 1 # gl errors otherwise + self._fbo_rect.texture = self._fbo.texture + self._fbo_rect.size = self.size + self._fbo_rect.pos = self.pos + self._background_rect.size = self.size + self._update_ticks(size) + self._update_plots(size) + + def _clear_buffer(self, *largs): + fbo = self._fbo + fbo.bind() + fbo.clear_buffer() + fbo.release() + + def add_plot(self, plot): + '''Add a new plot to this graph. + + :Parameters: + `plot`: + Plot to add to this graph. + + >>> graph = Graph() + >>> plot = MeshLinePlot(mode='line_strip', color=[1, 0, 0, 1]) + >>> plot.points = [(x / 10., sin(x / 50.)) for x in range(-0, 101)] + >>> graph.add_plot(plot) + ''' + if plot in self.plots: + return + add = self._plot_area.canvas.add + for instr in plot.get_drawings(): + add(instr) + plot.bind(on_clear_plot=self._clear_buffer) + self.plots.append(plot) + + def remove_plot(self, plot): + '''Remove a plot from this graph. + + :Parameters: + `plot`: + Plot to remove from this graph. + + >>> graph = Graph() + >>> plot = MeshLinePlot(mode='line_strip', color=[1, 0, 0, 1]) + >>> plot.points = [(x / 10., sin(x / 50.)) for x in range(-0, 101)] + >>> graph.add_plot(plot) + >>> graph.remove_plot(plot) + ''' + if plot not in self.plots: + return + remove = self._plot_area.canvas.remove + for instr in plot.get_drawings(): + remove(instr) + plot.unbind(on_clear_plot=self._clear_buffer) + self.plots.remove(plot) + self._clear_buffer() + + def collide_plot(self, x, y): + '''Determine if the given coordinates fall inside the plot area. Use + `x, y = self.to_widget(x, y, relative=True)` to first convert into + widget coordinates if it's in window coordinates because it's assumed + to be given in local widget coordinates, relative to the graph's pos. + + :Parameters: + `x, y`: + The coordinates to test. + ''' + adj_x, adj_y = x - self._plot_area.pos[0], y - self._plot_area.pos[1] + return 0 <= adj_x <= self._plot_area.size[0] \ + and 0 <= adj_y <= self._plot_area.size[1] + + def to_data(self, x, y): + '''Convert widget coords to data coords. Use + `x, y = self.to_widget(x, y, relative=True)` to first convert into + widget coordinates if it's in window coordinates because it's assumed + to be given in local widget coordinates, relative to the graph's pos. + + :Parameters: + `x, y`: + The coordinates to convert. + + If the graph has multiple axes, use :class:`Plot.unproject` instead. + ''' + adj_x = float(x - self._plot_area.pos[0]) + adj_y = float(y - self._plot_area.pos[1]) + norm_x = adj_x / self._plot_area.size[0] + norm_y = adj_y / self._plot_area.size[1] + if self.xlog: + xmin, xmax = log10(self.xmin), log10(self.xmax) + conv_x = 10.**(norm_x * (xmax - xmin) + xmin) + else: + conv_x = norm_x * (self.xmax - self.xmin) + self.xmin + if self.ylog: + ymin, ymax = log10(self.ymin), log10(self.ymax) + conv_y = 10.**(norm_y * (ymax - ymin) + ymin) + else: + conv_y = norm_y * (self.ymax - self.ymin) + self.ymin + return [conv_x, conv_y] + + xmin = NumericProperty(0.) + '''The x-axis minimum value. + + If :data:`xlog` is True, xmin must be larger than zero. + + :data:`xmin` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + xmax = NumericProperty(100.) + '''The x-axis maximum value, larger than xmin. + + :data:`xmax` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + xlog = BooleanProperty(False) + '''Determines whether the x-axis should be displayed logarithmically (True) + or linearly (False). + + :data:`xlog` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + x_ticks_major = BoundedNumericProperty(0, min=0) + '''Distance between major tick marks on the x-axis. + + Determines the distance between the major tick marks. Major tick marks + start from min and re-occur at every ticks_major until :data:`xmax`. + If :data:`xmax` doesn't overlap with a integer multiple of ticks_major, + no tick will occur at :data:`xmax`. Zero indicates no tick marks. + + If :data:`xlog` is true, then this indicates the distance between ticks + in multiples of current decade. E.g. if :data:`xmin` is 0.1 and + ticks_major is 0.1, it means there will be a tick at every 10th of the + decade, i.e. 0.1 ... 0.9, 1, 2... If it is 0.3, the ticks will occur at + 0.1, 0.3, 0.6, 0.9, 2, 5, 8, 10. You'll notice that it went from 8 to 10 + instead of to 20, that's so that we can say 0.5 and have ticks at every + half decade, e.g. 0.1, 0.5, 1, 5, 10, 50... Similarly, if ticks_major is + 1.5, there will be ticks at 0.1, 5, 100, 5,000... Also notice, that there's + always a major tick at the start. Finally, if e.g. :data:`xmin` is 0.6 + and this 0.5 there will be ticks at 0.6, 1, 5... + + :data:`x_ticks_major` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + x_ticks_minor = BoundedNumericProperty(0, min=0) + '''The number of sub-intervals that divide x_ticks_major. + + Determines the number of sub-intervals into which ticks_major is divided, + if non-zero. The actual number of minor ticks between the major ticks is + ticks_minor - 1. Only used if ticks_major is non-zero. If there's no major + tick at xmax then the number of minor ticks after the last major + tick will be however many ticks fit until xmax. + + If self.xlog is true, then this indicates the number of intervals the + distance between major ticks is divided. The result is the number of + multiples of decades between ticks. I.e. if ticks_minor is 10, then if + ticks_major is 1, there will be ticks at 0.1, 0.2...0.9, 1, 2, 3... If + ticks_major is 0.3, ticks will occur at 0.1, 0.12, 0.15, 0.18... Finally, + as is common, if ticks major is 1, and ticks minor is 5, there will be + ticks at 0.1, 0.2, 0.4... 0.8, 1, 2... + + :data:`x_ticks_minor` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + x_grid = BooleanProperty(False) + '''Determines whether the x-axis has tick marks or a full grid. + + If :data:`x_ticks_major` is non-zero, then if x_grid is False tick marks + will be displayed at every major tick. If x_grid is True, instead of ticks, + a vertical line will be displayed at every major tick. + + :data:`x_grid` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + x_grid_label = BooleanProperty(False) + '''Whether labels should be displayed beneath each major tick. If true, + each major tick will have a label containing the axis value. + + :data:`x_grid_label` is a :class:`~kivy.properties.BooleanProperty`, + defaults to False. + ''' + + xlabel = StringProperty('') + '''The label for the x-axis. If not empty it is displayed in the center of + the axis. + + :data:`xlabel` is a :class:`~kivy.properties.StringProperty`, + defaults to ''. + ''' + + ymin = NumericProperty(0.) + '''The y-axis minimum value. + + If :data:`ylog` is True, ymin must be larger than zero. + + :data:`ymin` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + ymax = NumericProperty(100.) + '''The y-axis maximum value, larger than ymin. + + :data:`ymax` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + ylog = BooleanProperty(False) + '''Determines whether the y-axis should be displayed logarithmically (True) + or linearly (False). + + :data:`ylog` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + y_ticks_major = BoundedNumericProperty(0, min=0) + '''Distance between major tick marks. See :data:`x_ticks_major`. + + :data:`y_ticks_major` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + y_ticks_minor = BoundedNumericProperty(0, min=0) + '''The number of sub-intervals that divide ticks_major. + See :data:`x_ticks_minor`. + + :data:`y_ticks_minor` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + y_grid = BooleanProperty(False) + '''Determines whether the y-axis has tick marks or a full grid. See + :data:`x_grid`. + + :data:`y_grid` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + y_grid_label = BooleanProperty(False) + '''Whether labels should be displayed beneath each major tick. If true, + each major tick will have a label containing the axis value. + + :data:`y_grid_label` is a :class:`~kivy.properties.BooleanProperty`, + defaults to False. + ''' + + ylabel = StringProperty('') + '''The label for the y-axis. If not empty it is displayed in the center of + the axis. + + :data:`ylabel` is a :class:`~kivy.properties.StringProperty`, + defaults to ''. + ''' + + padding = NumericProperty('5dp') + '''Padding distances between the labels, axes titles and graph, as + well between the widget and the objects near the boundaries. + + :data:`padding` is a :class:`~kivy.properties.NumericProperty`, defaults + to 5dp. + ''' + + font_size = NumericProperty('15sp') + '''Font size of the labels. + + :data:`font_size` is a :class:`~kivy.properties.NumericProperty`, defaults + to 15sp. + ''' + + x_ticks_angle = NumericProperty(0) + '''Rotate angle of the x-axis tick marks. + + :data:`x_ticks_angle` is a :class:`~kivy.properties.NumericProperty`, + defaults to 0. + ''' + + precision = StringProperty('%g') + '''Determines the numerical precision of the tick mark labels. This value + governs how the numbers are converted into string representation. Accepted + values are those listed in Python's manual in the + "String Formatting Operations" section. + + :data:`precision` is a :class:`~kivy.properties.StringProperty`, defaults + to '%g'. + ''' + + draw_border = BooleanProperty(True) + '''Whether a border is drawn around the canvas of the graph where the + plots are displayed. + + :data:`draw_border` is a :class:`~kivy.properties.BooleanProperty`, + defaults to True. + ''' + + plots = ListProperty([]) + '''Holds a list of all the plots in the graph. To add and remove plots + from the graph use :data:`add_plot` and :data:`add_plot`. Do not add + directly edit this list. + + :data:`plots` is a :class:`~kivy.properties.ListProperty`, + defaults to []. + ''' + + view_size = ObjectProperty((0, 0)) + '''The size of the graph viewing area - the area where the plots are + displayed, excluding labels etc. + ''' + + view_pos = ObjectProperty((0, 0)) + '''The pos of the graph viewing area - the area where the plots are + displayed, excluding labels etc. It is relative to the graph's pos. + ''' + + diff --git a/build/lib/kivycharts/graph/beziercurve.py b/build/lib/kivycharts/graph/beziercurve.py new file mode 100644 index 0000000..1535558 --- /dev/null +++ b/build/lib/kivycharts/graph/beziercurve.py @@ -0,0 +1,14 @@ +# briercurve.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.x1 number 0 起始点横坐标。 +opts.shape.y1 number 0 起始点纵坐标。 +opts.shape.x2 number 0 终止点横坐标。 +opts.shape.y2 number 0 终止点纵坐标。 +opts.shape.cpx1 number 0 控制点横坐标。 +opts.shape.cpy1 number 0 控制点纵坐标。 +opts.shape.percent number 1 已显示的百分比,用于绘制动画。 +""" + diff --git a/build/lib/kivycharts/graph/circle.py b/build/lib/kivycharts/graph/circle.py new file mode 100644 index 0000000..dc15c8c --- /dev/null +++ b/build/lib/kivycharts/graph/circle.py @@ -0,0 +1,10 @@ +# circle.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.r number 0 半径。 +""" + diff --git a/build/lib/kivycharts/graph/compoundpath.py b/build/lib/kivycharts/graph/compoundpath.py new file mode 100644 index 0000000..7f72431 --- /dev/null +++ b/build/lib/kivycharts/graph/compoundpath.py @@ -0,0 +1,7 @@ +# compoundpath.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape.paths Path[] null 路径数组。 +""" + diff --git a/build/lib/kivycharts/graph/displayable.py b/build/lib/kivycharts/graph/displayable.py new file mode 100644 index 0000000..1f40182 --- /dev/null +++ b/build/lib/kivycharts/graph/displayable.py @@ -0,0 +1,5 @@ +# displayable.py +""" +base class for all the plot class +""" + diff --git a/build/lib/kivycharts/graph/droplet.py b/build/lib/kivycharts/graph/droplet.py new file mode 100644 index 0000000..30c98f8 --- /dev/null +++ b/build/lib/kivycharts/graph/droplet.py @@ -0,0 +1,11 @@ +# droplet.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.width number 0 宽度。 +opts.shape.height number 0 高度。 +""" + diff --git a/build/lib/kivycharts/graph/ellipse.py b/build/lib/kivycharts/graph/ellipse.py new file mode 100644 index 0000000..50c3d72 --- /dev/null +++ b/build/lib/kivycharts/graph/ellipse.py @@ -0,0 +1,11 @@ +# ellipse.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.rx number 0 横向半径。 +opts.shape.ry number 0 纵向半径。 +""" + diff --git a/build/lib/kivycharts/graph/heart.py b/build/lib/kivycharts/graph/heart.py new file mode 100644 index 0000000..df14fb7 --- /dev/null +++ b/build/lib/kivycharts/graph/heart.py @@ -0,0 +1,11 @@ +# heart.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.width number 0 宽度。 +opts.shape.height number 0 高度。 +""" + diff --git a/build/lib/kivycharts/graph/image.py b/build/lib/kivycharts/graph/image.py new file mode 100644 index 0000000..6afc08f --- /dev/null +++ b/build/lib/kivycharts/graph/image.py @@ -0,0 +1,12 @@ +# image.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.style Object 样式。 +opts.style.image string|HTMLImageElement|HTMLCanvasElement 图片的内容,可以是图片的 URL,也可以是 dataURI。 +opts.style.x number 图片左上角相对于父节点的横坐标。 +opts.style.y number 图片左上角相对于父节点的纵坐标。 +opts.style.width number 图片宽度。 +opts.style.height number 图片高度。 +""" + diff --git a/build/lib/kivycharts/graph/line.py b/build/lib/kivycharts/graph/line.py new file mode 100644 index 0000000..6f74758 --- /dev/null +++ b/build/lib/kivycharts/graph/line.py @@ -0,0 +1,12 @@ +# line.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.x1 number 0 起始点横坐标。 +opts.shape.y1 number 0 起始点纵坐标。 +opts.shape.x2 number 0 终止点横坐标。 +opts.shape.y2 number 0 终止点纵坐标。 +opts.shape.percent number 1 已显示的百分比,用于绘制动画。 +""" + diff --git a/build/lib/kivycharts/graph/lsogon.py b/build/lib/kivycharts/graph/lsogon.py new file mode 100644 index 0000000..0dca14d --- /dev/null +++ b/build/lib/kivycharts/graph/lsogon.py @@ -0,0 +1,10 @@ +# lsogon.py +""" +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.x number 0 圆心横坐标。 +opts.shape.y number 0 圆心纵坐标。 +opts.shape.r number 0 半径。 +opts.shape.n number 0 边数。 +""" + diff --git a/build/lib/kivycharts/graph/path.py b/build/lib/kivycharts/graph/path.py new file mode 100644 index 0000000..e2453a1 --- /dev/null +++ b/build/lib/kivycharts/graph/path.py @@ -0,0 +1,14 @@ +# path.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +""" + + +""" +path.extend +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.type string 类型,自定义的名称。 +opts.init Function 初始化时调用的函数。 +opts.buildPath Function 如何构建 Path 的函数,在绘制时候会调用。 +""" diff --git a/build/lib/kivycharts/graph/plot.py b/build/lib/kivycharts/graph/plot.py new file mode 100644 index 0000000..62d5121 --- /dev/null +++ b/build/lib/kivycharts/graph/plot.py @@ -0,0 +1,795 @@ + +from kivy.uix.widget import Widget +from kivy.uix.label import Label +from kivy.uix.stencilview import StencilView +from kivy.properties import NumericProperty, BooleanProperty,\ + BoundedNumericProperty, StringProperty, ListProperty, ObjectProperty,\ + DictProperty, AliasProperty +from kivy.clock import Clock +from kivy.graphics import Mesh, Color, Rectangle, Point +from kivy.graphics import Fbo +from kivy.graphics.texture import Texture +from kivy.event import EventDispatcher +from kivy.lang import Builder +from kivy.logger import Logger +from kivy import metrics +from math import log10, floor, ceil +from decimal import Decimal +from itertools import chain +try: + import numpy as np +except ImportError as e: + np = None + +class Plot(EventDispatcher): + '''Plot class, see module documentation for more information. + + :Events: + `on_clear_plot` + Fired before a plot updates the display and lets the fbo know that + it should clear the old drawings. + + ..versionadded:: 0.4 + ''' + + __events__ = ('on_clear_plot', ) + + # most recent values of the params used to draw the plot + params = DictProperty({'xlog': False, 'xmin': 0, 'xmax': 100, + 'ylog': False, 'ymin': 0, 'ymax': 100, + 'size': (0, 0, 0, 0)}) + + color = ListProperty([1, 1, 1, 1]) + '''Color of the plot. + ''' + + points = ListProperty([]) + '''List of (x, y) points to be displayed in the plot. + + The elements of points are 2-tuples, (x, y). The points are displayed + based on the mode setting. + + :data:`points` is a :class:`~kivy.properties.ListProperty`, defaults to + []. + ''' + + x_axis = NumericProperty(0) + '''Index of the X axis to use, defaults to 0 + ''' + + y_axis = NumericProperty(0) + '''Index of the Y axis to use, defaults to 0 + ''' + + def __init__(self, **kwargs): + super(Plot, self).__init__(**kwargs) + self.ask_draw = Clock.create_trigger(self.draw) + self.bind(params=self.ask_draw, points=self.ask_draw) + self._drawings = self.create_drawings() + + def funcx(self): + """Return a function that convert or not the X value according to plot + prameters""" + return log10 if self.params["xlog"] else lambda x: x + + def funcy(self): + """Return a function that convert or not the Y value according to plot + prameters""" + return log10 if self.params["ylog"] else lambda y: y + + def x_px(self): + """Return a function that convert the X value of the graph to the + pixel coordinate on the plot, according to the plot settings and axis + settings. It's relative to the graph pos. + """ + funcx = self.funcx() + params = self.params + size = params["size"] + xmin = funcx(params["xmin"]) + xmax = funcx(params["xmax"]) + ratiox = (size[2] - size[0]) / float(xmax - xmin) + return lambda x: (funcx(x) - xmin) * ratiox + size[0] + + def y_px(self): + """Return a function that convert the Y value of the graph to the + pixel coordinate on the plot, according to the plot settings and axis + settings. The returned value is relative to the graph pos. + """ + funcy = self.funcy() + params = self.params + size = params["size"] + ymin = funcy(params["ymin"]) + ymax = funcy(params["ymax"]) + ratioy = (size[3] - size[1]) / float(ymax - ymin) + return lambda y: (funcy(y) - ymin) * ratioy + size[1] + + def unproject(self, x, y): + """Return a function that unproject a pixel to a X/Y value on the plot + (works only for linear, not log yet). `x`, `y`, is relative to the + graph pos, so the graph's pos needs to be subtracted from x, y before + passing it in. + """ + params = self.params + size = params["size"] + xmin = params["xmin"] + xmax = params["xmax"] + ymin = params["ymin"] + ymax = params["ymax"] + ratiox = (size[2] - size[0]) / float(xmax - xmin) + ratioy = (size[3] - size[1]) / float(ymax - ymin) + x0 = (x - size[0]) / ratiox + xmin + y0 = (y - size[1]) / ratioy + ymin + return x0, y0 + + def get_px_bounds(self): + """Returns a dict containing the pixels bounds from the plot parameters. + The returned values are relative to the graph pos. + """ + params = self.params + x_px = self.x_px() + y_px = self.y_px() + return { + "xmin": x_px(params["xmin"]), + "xmax": x_px(params["xmax"]), + "ymin": y_px(params["ymin"]), + "ymax": y_px(params["ymax"]), + } + + def update(self, xlog, xmin, xmax, ylog, ymin, ymax, size): + '''Called by graph whenever any of the parameters + change. The plot should be recalculated then. + log, min, max indicate the axis settings. + size a 4-tuple describing the bounding box in which we can draw + graphs, it's (x0, y0, x1, y1), which correspond with the bottom left + and top right corner locations, respectively. + ''' + self.params.update({ + 'xlog': xlog, 'xmin': xmin, 'xmax': xmax, 'ylog': ylog, + 'ymin': ymin, 'ymax': ymax, 'size': size}) + + def get_group(self): + '''returns a string which is unique and is the group name given to all + the instructions returned by _get_drawings. Graph uses this to remove + these instructions when needed. + ''' + return '' + + def get_drawings(self): + '''returns a list of canvas instructions that will be added to the + graph's canvas. + ''' + if isinstance(self._drawings, (tuple, list)): + return self._drawings + return [] + + def create_drawings(self): + '''called once to create all the canvas instructions needed for the + plot + ''' + pass + + def draw(self, *largs): + '''draw the plot according to the params. It dispatches on_clear_plot + so derived classes should call super before updating. + ''' + self.dispatch('on_clear_plot') + + def iterate_points(self): + '''Iterate on all the points adjusted to the graph settings + ''' + x_px = self.x_px() + y_px = self.y_px() + for x, y in self.points: + yield x_px(x), y_px(y) + + def on_clear_plot(self, *largs): + pass + + # compatibility layer + _update = update + _get_drawings = get_drawings + _params = params + + +class MeshLinePlot(Plot): + '''MeshLinePlot class which displays a set of points similar to a mesh. + ''' + + def _set_mode(self, value): + if hasattr(self, '_mesh'): + self._mesh.mode = value + + mode = AliasProperty(lambda self: self._mesh.mode, _set_mode) + '''VBO Mode used for drawing the points. Can be one of: 'points', + 'line_strip', 'line_loop', 'lines', 'triangle_strip', 'triangle_fan'. + See :class:`~kivy.graphics.Mesh` for more details. + + Defaults to 'line_strip'. + ''' + + def create_drawings(self): + self._color = Color(*self.color) + self._mesh = Mesh(mode='line_strip') + self.bind( + color=lambda instr, value: setattr(self._color, "rgba", value)) + return [self._color, self._mesh] + + def draw(self, *args): + super(MeshLinePlot, self).draw(*args) + self.plot_mesh() + + def plot_mesh(self): + points = [p for p in self.iterate_points()] + mesh, vert, _ = self.set_mesh_size(len(points)) + for k, (x, y) in enumerate(points): + vert[k * 4] = x + vert[k * 4 + 1] = y + mesh.vertices = vert + + def set_mesh_size(self, size): + mesh = self._mesh + vert = mesh.vertices + ind = mesh.indices + diff = size - len(vert) // 4 + if diff < 0: + del vert[4 * size:] + del ind[size:] + elif diff > 0: + ind.extend(range(len(ind), len(ind) + diff)) + vert.extend([0] * (diff * 4)) + mesh.vertices = vert + return mesh, vert, ind + + +class MeshStemPlot(MeshLinePlot): + '''MeshStemPlot uses the MeshLinePlot class to draw a stem plot. The data + provided is graphed from origin to the data point. + ''' + + def plot_mesh(self): + points = [p for p in self.iterate_points()] + mesh, vert, _ = self.set_mesh_size(len(points) * 2) + y0 = self.y_px()(0) + for k, (x, y) in enumerate(self.iterate_points()): + vert[k * 8] = x + vert[k * 8 + 1] = y0 + vert[k * 8 + 4] = x + vert[k * 8 + 5] = y + mesh.vertices = vert + + +class LinePlot(Plot): + """LinePlot draws using a standard Line object. + """ + + line_width = NumericProperty(1) + + def create_drawings(self): + from kivy.graphics import Line, RenderContext + + self._grc = RenderContext( + use_parent_modelview=True, + use_parent_projection=True) + with self._grc: + self._gcolor = Color(*self.color) + self._gline = Line( + points=[], cap='none', + width=self.line_width, joint='round') + + return [self._grc] + + def draw(self, *args): + super(LinePlot, self).draw(*args) + # flatten the list + points = [] + for x, y in self.iterate_points(): + points += [x, y] + self._gline.points = points + + def on_line_width(self, *largs): + if hasattr(self, "_gline"): + self._gline.width = self.line_width + + +class SmoothLinePlot(Plot): + '''Smooth Plot class, see module documentation for more information. + This plot use a specific Fragment shader for a custom anti aliasing. + ''' + + SMOOTH_FS = ''' + $HEADER$ + + void main(void) { + float edgewidth = 0.015625 * 64.; + float t = texture2D(texture0, tex_coord0).r; + float e = smoothstep(0., edgewidth, t); + gl_FragColor = frag_color * vec4(1, 1, 1, e); + } + ''' + + # XXX This gradient data is a 64x1 RGB image, and + # values goes from 0 -> 255 -> 0. + GRADIENT_DATA = ( + b"\x00\x00\x00\x07\x07\x07\x0f\x0f\x0f\x17\x17\x17\x1f\x1f\x1f" + b"'''///777???GGGOOOWWW___gggooowww\x7f\x7f\x7f\x87\x87\x87" + b"\x8f\x8f\x8f\x97\x97\x97\x9f\x9f\x9f\xa7\xa7\xa7\xaf\xaf\xaf" + b"\xb7\xb7\xb7\xbf\xbf\xbf\xc7\xc7\xc7\xcf\xcf\xcf\xd7\xd7\xd7" + b"\xdf\xdf\xdf\xe7\xe7\xe7\xef\xef\xef\xf7\xf7\xf7\xff\xff\xff" + b"\xf6\xf6\xf6\xee\xee\xee\xe6\xe6\xe6\xde\xde\xde\xd5\xd5\xd5" + b"\xcd\xcd\xcd\xc5\xc5\xc5\xbd\xbd\xbd\xb4\xb4\xb4\xac\xac\xac" + b"\xa4\xa4\xa4\x9c\x9c\x9c\x94\x94\x94\x8b\x8b\x8b\x83\x83\x83" + b"{{{sssjjjbbbZZZRRRJJJAAA999111))) \x18\x18\x18\x10\x10\x10" + b"\x08\x08\x08\x00\x00\x00") + + def create_drawings(self): + from kivy.graphics import Line, RenderContext + + # very first time, create a texture for the shader + if not hasattr(SmoothLinePlot, '_texture'): + tex = Texture.create(size=(1, 64), colorfmt='rgb') + tex.add_reload_observer(SmoothLinePlot._smooth_reload_observer) + SmoothLinePlot._texture = tex + SmoothLinePlot._smooth_reload_observer(tex) + + self._grc = RenderContext( + fs=SmoothLinePlot.SMOOTH_FS, + use_parent_modelview=True, + use_parent_projection=True) + with self._grc: + self._gcolor = Color(*self.color) + self._gline = Line( + points=[], cap='none', width=2., + texture=SmoothLinePlot._texture) + + return [self._grc] + + @staticmethod + def _smooth_reload_observer(texture): + texture.blit_buffer(SmoothLinePlot.GRADIENT_DATA, colorfmt="rgb") + + def draw(self, *args): + super(SmoothLinePlot, self).draw(*args) + # flatten the list + points = [] + for x, y in self.iterate_points(): + points += [x, y] + self._gline.points = points + + +class ContourPlot(Plot): + """ + ContourPlot visualizes 3 dimensional data as an intensity map image. + The user must first specify 'xrange' and 'yrange' (tuples of min,max) and + then 'data', the intensity values. + `data`, is a MxN matrix, where the first dimension of size M specifies the + `y` values, and the second dimension of size N specifies the `x` values. + Axis Y and X values are assumed to be linearly spaced values from + xrange/yrange and the dimensions of 'data', `MxN`, respectively. + The color values are automatically scaled to the min and max z range of the + data set. + """ + _image = ObjectProperty(None) + data = ObjectProperty(None, force_dispatch=True) + xrange = ListProperty([0, 100]) + yrange = ListProperty([0, 100]) + + def __init__(self, **kwargs): + super(ContourPlot, self).__init__(**kwargs) + self.bind(data=self.ask_draw, xrange=self.ask_draw, + yrange=self.ask_draw) + + def create_drawings(self): + self._image = Rectangle() + self._color = Color([1, 1, 1, 1]) + self.bind( + color=lambda instr, value: setattr(self._color, 'rgba', value)) + return [self._color, self._image] + + def draw(self, *args): + super(ContourPlot, self).draw(*args) + data = self.data + xdim, ydim = data.shape + + # Find the minimum and maximum z values + zmax = data.max() + zmin = data.min() + rgb_scale_factor = 1.0 / (zmax - zmin) * 255 + # Scale the z values into RGB data + buf = np.array(data, dtype=float, copy=True) + np.subtract(buf, zmin, out=buf) + np.multiply(buf, rgb_scale_factor, out=buf) + # Duplicate into 3 dimensions (RGB) and convert to byte array + buf = np.asarray(buf, dtype=np.uint8) + buf = np.expand_dims(buf, axis=2) + buf = np.concatenate((buf, buf, buf), axis=2) + buf = np.reshape(buf, (xdim, ydim, 3)) + + charbuf = bytearray(np.reshape(buf, (buf.size))) + self._texture = Texture.create(size=(xdim, ydim), colorfmt='rgb') + self._texture.blit_buffer(charbuf, colorfmt='rgb', bufferfmt='ubyte') + image = self._image + image.texture = self._texture + + x_px = self.x_px() + y_px = self.y_px() + bl = x_px(self.xrange[0]), y_px(self.yrange[0]) + tr = x_px(self.xrange[1]), y_px(self.yrange[1]) + image.pos = bl + w = tr[0] - bl[0] + h = tr[1] - bl[1] + image.size = (w, h) + + +class BarPlot(Plot): + '''BarPlot class which displays a bar graph. + ''' + + bar_width = NumericProperty(1) + bar_spacing = NumericProperty(1.) + graph = ObjectProperty(allownone=True) + + def __init__(self, *ar, **kw): + super(BarPlot, self).__init__(*ar, **kw) + self.bind(bar_width=self.ask_draw) + self.bind(points=self.update_bar_width) + self.bind(graph=self.update_bar_width) + + def update_bar_width(self, *ar): + if not self.graph: + return + if len(self.points) < 2: + return + if self.graph.xmax == self.graph.xmin: + return + + point_width = ( + len(self.points) * + float(abs(self.graph.xmax) + abs(self.graph.xmin)) / + float(abs(max(self.points)[0]) + abs(min(self.points)[0]))) + + if not self.points: + self.bar_width = 1 + else: + self.bar_width = ( + (self.graph.width - self.graph.padding) / + point_width * self.bar_spacing) + + def create_drawings(self): + self._color = Color(*self.color) + self._mesh = Mesh() + self.bind( + color=lambda instr, value: setattr(self._color, 'rgba', value)) + return [self._color, self._mesh] + + def draw(self, *args): + super(BarPlot, self).draw(*args) + points = self.points + + # The mesh only supports (2^16) - 1 indices, so... + if len(points) * 6 > 65535: + Logger.error( + "BarPlot: cannot support more than 10922 points. " + "Ignoring extra points.") + points = points[:10922] + + point_len = len(points) + mesh = self._mesh + mesh.mode = 'triangles' + vert = mesh.vertices + ind = mesh.indices + diff = len(points) * 6 - len(vert) // 4 + if diff < 0: + del vert[24 * point_len:] + del ind[point_len:] + elif diff > 0: + ind.extend(range(len(ind), len(ind) + diff)) + vert.extend([0] * (diff * 4)) + + bounds = self.get_px_bounds() + x_px = self.x_px() + y_px = self.y_px() + ymin = y_px(0) + + bar_width = self.bar_width + if bar_width < 0: + bar_width = x_px(bar_width) - bounds["xmin"] + + for k in range(point_len): + p = points[k] + x1 = x_px(p[0]) + x2 = x1 + bar_width + y1 = ymin + y2 = y_px(p[1]) + + idx = k * 24 + # first triangle + vert[idx] = x1 + vert[idx + 1] = y2 + vert[idx + 4] = x1 + vert[idx + 5] = y1 + vert[idx + 8] = x2 + vert[idx + 9] = y1 + # second triangle + vert[idx + 12] = x1 + vert[idx + 13] = y2 + vert[idx + 16] = x2 + vert[idx + 17] = y2 + vert[idx + 20] = x2 + vert[idx + 21] = y1 + mesh.vertices = vert + + def _unbind_graph(self, graph): + graph.unbind(width=self.update_bar_width, + xmin=self.update_bar_width, + ymin=self.update_bar_width) + + def bind_to_graph(self, graph): + old_graph = self.graph + + if old_graph: + # unbind from the old one + self._unbind_graph(old_graph) + + # bind to the new one + self.graph = graph + graph.bind(width=self.update_bar_width, + xmin=self.update_bar_width, + ymin=self.update_bar_width) + + def unbind_from_graph(self): + if self.graph: + self._unbind_graph(self.graph) + + +class HBar(MeshLinePlot): + '''HBar draw horizontal bar on all the Y points provided + ''' + + def plot_mesh(self, *args): + points = self.points + mesh, vert, ind = self.set_mesh_size(len(points) * 2) + mesh.mode = "lines" + + bounds = self.get_px_bounds() + px_xmin = bounds["xmin"] + px_xmax = bounds["xmax"] + y_px = self.y_px() + for k, y in enumerate(points): + y = y_px(y) + vert[k * 8] = px_xmin + vert[k * 8 + 1] = y + vert[k * 8 + 4] = px_xmax + vert[k * 8 + 5] = y + mesh.vertices = vert + + +class VBar(MeshLinePlot): + '''VBar draw vertical bar on all the X points provided + ''' + + def plot_mesh(self, *args): + points = self.points + mesh, vert, ind = self.set_mesh_size(len(points) * 2) + mesh.mode = "lines" + + bounds = self.get_px_bounds() + px_ymin = bounds["ymin"] + px_ymax = bounds["ymax"] + x_px = self.x_px() + for k, x in enumerate(points): + x = x_px(x) + vert[k * 8] = x + vert[k * 8 + 1] = px_ymin + vert[k * 8 + 4] = x + vert[k * 8 + 5] = px_ymax + mesh.vertices = vert + + +class ScatterPlot(Plot): + """ + ScatterPlot draws using a standard Point object. + The pointsize can be controlled with :attr:`point_size`. + + >>> plot = ScatterPlot(color=[1, 0, 0, 1], point_size=5) + """ + + point_size = NumericProperty(1) + """The point size of the scatter points. Defaults to 1. + """ + + def create_drawings(self): + from kivy.graphics import Point, RenderContext + + self._points_context = RenderContext( + use_parent_modelview=True, + use_parent_projection=True) + with self._points_context: + self._gcolor = Color(*self.color) + self._gpts = Point(points=[], pointsize=self.point_size) + + return [self._points_context] + + def draw(self, *args): + super(ScatterPlot, self).draw(*args) + # flatten the list + self._gpts.points = list(chain(*self.iterate_points())) + + def on_point_size(self, *largs): + if hasattr(self, "_gpts"): + self._gpts.pointsize = self.point_size + + +class PointPlot(Plot): + '''Displays a set of points. + ''' + + point_size = NumericProperty(1) + ''' + Defaults to 1. + ''' + + _color = None + + _point = None + + def __init__(self, **kwargs): + super(PointPlot, self).__init__(**kwargs) + + def update_size(*largs): + if self._point: + self._point.pointsize = self.point_size + self.fbind('point_size', update_size) + + def update_color(*largs): + if self._color: + self._color.rgba = self.color + self.fbind('color', update_color) + + def create_drawings(self): + self._color = Color(*self.color) + self._point = Point(pointsize=self.point_size) + return [self._color, self._point] + + def draw(self, *args): + super(PointPlot, self).draw(*args) + self._point.points = [v for p in self.iterate_points() for v in p] + + +if __name__ == '__main__': + import itertools + from math import sin, cos, pi + from random import randrange + from kivy.utils import get_color_from_hex as rgb + from kivy.uix.boxlayout import BoxLayout + from kivy.app import App + + class TestApp(App): + + def build(self): + b = BoxLayout(orientation='vertical') + # example of a custom theme + colors = itertools.cycle([ + rgb('7dac9f'), rgb('dc7062'), rgb('66a8d4'), rgb('e5b060')]) + graph_theme = { + 'label_options': { + 'color': rgb('444444'), # color of tick labels and titles + 'bold': True}, + 'background_color': rgb('f8f8f2'), # canvas background color + 'tick_color': rgb('808080'), # ticks and grid + 'border_color': rgb('808080')} # border drawn around each graph + + graph = Graph( + xlabel='Cheese', + ylabel='Apples', + x_ticks_minor=5, + x_ticks_major=25, + y_ticks_major=1, + y_grid_label=True, + x_grid_label=True, + padding=5, + xlog=False, + ylog=False, + x_grid=True, + y_grid=True, + xmin=-50, + xmax=50, + ymin=-1, + ymax=1, + **graph_theme) + + plot = SmoothLinePlot(color=next(colors)) + plot.points = [(x / 10., sin(x / 50.)) for x in range(-500, 501)] + # for efficiency, the x range matches xmin, xmax + graph.add_plot(plot) + + plot = MeshLinePlot(color=next(colors)) + plot.points = [(x / 10., cos(x / 50.)) for x in range(-500, 501)] + graph.add_plot(plot) + self.plot = plot # this is the moving graph, so keep a reference + + plot = MeshStemPlot(color=next(colors)) + graph.add_plot(plot) + plot.points = [(x, x / 50.) for x in range(-50, 51)] + + plot = BarPlot(color=next(colors), bar_spacing=.72) + graph.add_plot(plot) + plot.bind_to_graph(graph) + plot.points = [(x, .1 + randrange(10) / 10.) for x in range(-50, 1)] + + Clock.schedule_interval(self.update_points, 1 / 60.) + + graph2 = Graph( + xlabel='Position (m)', + ylabel='Time (s)', + x_ticks_minor=0, + x_ticks_major=1, + y_ticks_major=10, + y_grid_label=True, + x_grid_label=True, + padding=5, + xlog=False, + ylog=False, + xmin=0, + ymin=0, + **graph_theme) + b.add_widget(graph) + + if np is not None: + (xbounds, ybounds, data) = self.make_contour_data() + # This is required to fit the graph to the data extents + graph2.xmin, graph2.xmax = xbounds + graph2.ymin, graph2.ymax = ybounds + + plot = ContourPlot() + plot.data = data + plot.xrange = xbounds + plot.yrange = ybounds + plot.color = [1, 0.7, 0.2, 1] + graph2.add_plot(plot) + + b.add_widget(graph2) + self.contourplot = plot + + Clock.schedule_interval(self.update_contour, 1 / 60.) + + # Test the scatter plot + plot = ScatterPlot(color=next(colors), point_size=5) + graph.add_plot(plot) + plot.points = [(x, .1 + randrange(10) / 10.) for x in range(-50, 1)] + return b + + def make_contour_data(self, ts=0): + omega = 2 * pi / 30 + k = (2 * pi) / 2.0 + + ts = sin(ts * 2) + 1.5 # emperically determined 'pretty' values + npoints = 100 + data = np.ones((npoints, npoints)) + + position = [ii * 0.1 for ii in range(npoints)] + time = [(ii % 100) * 0.6 for ii in range(npoints)] + + for ii, t in enumerate(time): + for jj, x in enumerate(position): + data[ii, jj] = sin( + k * x + omega * t) + sin(-k * x + omega * t) / ts + return (0, max(position)), (0, max(time)), data + + def update_points(self, *args): + self.plot.points = [ + (x / 10., cos(Clock.get_time() + x / 50.)) + for x in range(-500, 501)] + + def update_contour(self, *args): + _, _, self.contourplot.data[:] = self.make_contour_data( + Clock.get_time()) + # this does not trigger an update, because we replace the + # values of the arry and do not change the object. + # However, we cannot do "...data = make_contour_data()" as + # kivy will try to check for the identity of the new and + # old values. In numpy, 'nd1 == nd2' leads to an error + # (you have to use np.all). Ideally, property should be patched + # for this. + self.contourplot.ask_draw() + + TestApp().run() diff --git a/build/lib/kivycharts/graph/polygon.py b/build/lib/kivycharts/graph/polygon.py new file mode 100644 index 0000000..33df3f5 --- /dev/null +++ b/build/lib/kivycharts/graph/polygon.py @@ -0,0 +1,10 @@ +# polygon.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.points number[][] 0 每个元素是一个横纵坐标的数组。 +opts.shape.smooth number|string 0 圆滑程度,取值范围为 0 到 1 之间的数字,0 表示不圆滑;也可以是特殊字符串 'spline' 表示用 Catmull-Rom spline 插值算法,否则默认用贝塞尔曲线插值算法。 +opts.shape.smoothConstraint number[][] 0 将计算出来的控制点约束在一个包围盒内。比如 [[0, 0], [100, 100]],这个包围盒会与整个折线的包围盒做一个并集用来约束控制点。 +""" + diff --git a/build/lib/kivycharts/graph/polyline.py b/build/lib/kivycharts/graph/polyline.py new file mode 100644 index 0000000..c72f638 --- /dev/null +++ b/build/lib/kivycharts/graph/polyline.py @@ -0,0 +1,10 @@ +# polyline.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.points number[][] 0 每个元素是一个横纵坐标的数组。 +opts.shape.smooth number|string 0 圆滑程度,取值范围为 0 到 1 之间的数字,0 表示不圆滑;也可以是特殊字符串 'spline' 表示用 Catmull-Rom spline 插值算法,否则默认用贝塞尔曲线插值算法。 +opts.shape.smoothConstraint number[][] 0 将计算出来的控制点约束在一个包围盒内。比如 [[0, 0], [100, 100]],这个包围盒会与整个折线的包围盒做一个并集用来约束控制点。 +""" + diff --git a/build/lib/kivycharts/graph/rectangle.py b/build/lib/kivycharts/graph/rectangle.py new file mode 100644 index 0000000..36e3083 --- /dev/null +++ b/build/lib/kivycharts/graph/rectangle.py @@ -0,0 +1,12 @@ +# rectangle.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.r number|number[] 0 用于创建圆角矩形。左上、右上、右下、左下角的半径依次为 r1、 r2、 r3、 r4。r 缩写为 1 相当于 [1, 1, 1, 1];r 缩写为 [1] 相当于 [1, 1, 1, 1];r 缩写为 [1, 2] 相当于 [1, 2, 1, 2];r 缩写为 [1, 2, 3] 相当于 [1, 2, 3, 2]。 +opts.shape.x number 0 左上角的横坐标。 +opts.shape.y number 0 左上角的纵坐标。 +opts.shape.width number 0 宽度。 +opts.shape.height number 0 高度。 +""" + diff --git a/build/lib/kivycharts/graph/rose.py b/build/lib/kivycharts/graph/rose.py new file mode 100644 index 0000000..b573447 --- /dev/null +++ b/build/lib/kivycharts/graph/rose.py @@ -0,0 +1,12 @@ +# rose.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.r number[] [] 半径。 +opts.shape.k number 0 玫瑰线参数,参见 wiki。 +opts.shape.n number 1 花瓣数。 +""" + diff --git a/build/lib/kivycharts/graph/sector.py b/build/lib/kivycharts/graph/sector.py new file mode 100644 index 0000000..e91f888 --- /dev/null +++ b/build/lib/kivycharts/graph/sector.py @@ -0,0 +1,14 @@ +# sector.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.r number 0 半径。 +opts.shape.r0 number 0 内半径。 +opts.shape.startAngle number 0 起始弧度。 +opts.shape.endAngle number Math.PI * 2 终止弧度。 +opts.shape.clockwise boolean true 顺时针方向。 +""" + diff --git a/build/lib/kivycharts/graph/star.py b/build/lib/kivycharts/graph/star.py new file mode 100644 index 0000000..e788dd4 --- /dev/null +++ b/build/lib/kivycharts/graph/star.py @@ -0,0 +1,13 @@ +# star.py +""" + +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.n number 3 瓣数,如 n 等于 5 时,是我们熟悉的五角星。 +opts.shape.r number 0 半径。 +opts.shape.r0 number 0 内半径。 +""" + diff --git a/build/lib/kivycharts/graph/text.py b/build/lib/kivycharts/graph/text.py new file mode 100644 index 0000000..9c24a40 --- /dev/null +++ b/build/lib/kivycharts/graph/text.py @@ -0,0 +1,9 @@ +# text.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +?text +font_size +? +""" + diff --git a/build/lib/kivycharts/graph/touchoid.py b/build/lib/kivycharts/graph/touchoid.py new file mode 100644 index 0000000..405cd90 --- /dev/null +++ b/build/lib/kivycharts/graph/touchoid.py @@ -0,0 +1,12 @@ +# touchoid.py +""" +名称 类型 默认值 描述 +opts Object 配置项,继承得到的配置项参见 zrender.Displayable。 +opts.shape Object 形状属性。 +opts.shape.cx number 0 圆心横坐标。 +opts.shape.cy number 0 圆心纵坐标。 +opts.shape.r number 0 半径。 +opts.shape.r0 number 0 内半径。 +opts.shape.d number 0 内外旋轮曲线参数,参见 wiki。 +opts.shape.n location 'out' out 或 in,表示曲线在内部还是外部。 +""" diff --git a/build/lib/kivycharts/line.py b/build/lib/kivycharts/line.py new file mode 100644 index 0000000..2007b2c --- /dev/null +++ b/build/lib/kivycharts/line.py @@ -0,0 +1,130 @@ +from kivy.properties import DictProperty, ListProperty, \ + StringProperty, NumericProperty +from kivy.factory import Factory +from kivy.utils import get_color_from_hex as rgb + +from kivyblocks.scrollpanel import ScrollPanel +from kivyblocks.utils import SUPER, CSize +from kivyblocks.threadcall import HttpClient +from kivyblocks.baseWidget import VBox + +from .graph import Graph +from .graph import Plot, LinePlot, SmoothLinePlot + +build_plots = { + 'fold-line':LinePlot, + 'smooth-line':SmoothLinePlot +} + +class LineChart(VBox): + """ + series = [ + { + yfield:xxxx, + charttype:smooth-line, fold-line, ... + color: + } + ] + """ + dataurl = StringProperty(None) + params = DictProperty({}) + method = StringProperty('get') + xlabel = StringProperty(None) + ylabel = StringProperty(None) + xfield = StringProperty(None) + x_ticks_angle = NumericProperty(45) + series = ListProperty(None) + data = ListProperty(None) + def __init__(self, **kw): + self.graph = None + SUPER(LineChart, self, kw) + + def on_dataurl(self, o, url=None): + if not self.dataurl: + return + hc = HttpClient() + x = hc(self.dataurl, + method=self.method, + params=self.params) + self.data = x['rows'] + self.url_call = True + + def on_params(self, o, params=None): + if not self.url_call: + return + self.on_dataurl(None, None) + + def build_plot(self, serie): + type = serie.get('charttype', 'smooth-line') + plotKlass = build_plots.get(type) + p = plotKlass(color=serie['color']) + return p + + def on_data(self, o, data=None): + graph_theme = { + 'label_options': { + 'color': rgb('444444'), # color of tick labels and titles + 'bold': True}, + 'background_color': rgb('f8f8f2'), # canvas background color + 'tick_color': rgb('808080'), # ticks and grid + 'border_color': rgb('808080')} # border drawn around each graph + + xcnt = len(self.data) + ymin, ymax = 9999999999999999, 0 + xlabel_text = [ r.get(self.xfield) for r in self.data ] + xlabel_text.insert(0,'0') + for s in self.series: + points = [ (i, r.get(s['yfield'])) \ + for i,r in enumerate(self.data) ] + min1 = min([p[1] for p in points]) + max1 = max([p[1] for p in points]) + if max1 >ymax: + ymax = max1 + if min1 < ymin: + ymin = min1 + s['points'] = points + yadd = int((ymax - ymin) / 8) + ymin = int(ymin - yadd) + ymax = int(ymax + yadd) + y_ticks_major = int((ymax-ymin)/4) + x_ticks_major = int(xcnt/10) + xlabel = self.xlabel or self.xfield + ylabel = self.ylabel + + if not self.graph: + self.graph = Graph( + xlabel = self.xlabel or self.xfield, + ylabel = self.ylabel, + y_ticks_major = int((ymax-ymin)/4), + x_ticks_major = int(xcnt/10), + ymin = ymin, + ymax = ymax, + xmin = 0, + xmax = xcnt, + y_grid_label=True, + x_grid_label=True, + xlog = False, + ylog = False, + x_grid = True, + y_grid = True, + padding = 5, + **graph_theme) + self.add_widget(self.graph) + else: + self.graph.xlabel = xlabel + self.graph.ylabel = ylabel + self.graph.ymin = ymin + self.graph.ymax = ymax + self.graph.xmin = 0 + self.graph.xmax = xmax + self.graph.y_ticks_major = y_ticks_major + self.graph.x_ticks_major = x_ticks_major + + for s in self.series: + s['plot'] = self.build_plot(s) + s['plot'].points = s['points'] + self.graph.add_plot(s['plot']) + self.graph.x_grid_texts = xlabel_text + self.graph.x_ticks_angle = self.x_ticks_angle + +Factory.register('LineChart', LineChart) diff --git a/build/lib/kivycharts/pie.py b/build/lib/kivycharts/pie.py new file mode 100644 index 0000000..fc11ce8 --- /dev/null +++ b/build/lib/kivycharts/pie.py @@ -0,0 +1,66 @@ + +import math +import numpy as np +from kivy.uix.widget import Widget +from kivyblocks.charts.geometry import EllipseUtils + +class PiePart(ChartPart): + def collide_point(self,x,y): + if not self.isInSideEllipse(x,y): + return False + return self.isInSidePart(x,y) + + def isInSideEllipse(self,x,y): + a = self.width / 2 + b = self.height / 2 + v = x ** x / a ** a + y**y / b ** b + if v <= 1: + return True + return False + +class Pie(Widget): + def __init__(self,**options): + """ + options={ + width, + height, + title, + keyField, + valueField, + data=[ + { + name, + value, + },{ + } + ] + } + """ + self.options = options + self.initflag = False + super().__init__() + self.bind(size=self.onSize,pos=self.onSize) + + def data2pie(self): + data = self.options.get('data',[]) + kvs = [ [i[self.options['keyField']],i[self.options['valueField']]] for i in data ] + total = sum([i[1] for i in kvs ]) + start_degree = 0 + cnt = len(kvs) + color1='8833ee' + color2='ed8234' + colors = divideColor(color1,color2,cnt-1) + self.canvas.clear() + for i in range(cnt): + degree = start_degree + 360 * kvs[i][1] / total + with self.canvas: + Color(*colors[i]) + Ellipse(pos=self.pos, + size=self.size, + angle_start=start_degree, + angle_end= degree) + start_degree = degree + + def onSize(self,o,v): + self.data2pie() + diff --git a/dist/kivycharts-0.0.5-py3.9.egg b/dist/kivycharts-0.0.5-py3.9.egg new file mode 100644 index 0000000000000000000000000000000000000000..29af3d877d73d5131fa35f17ade76dd59310afec GIT binary patch literal 71528 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 U009vM{y_oDInrRjYzpN60K%qLCjbBd literal 0 HcmV?d00001 diff --git a/kivycharts.egg-info/PKG-INFO b/kivycharts.egg-info/PKG-INFO new file mode 100644 index 0000000..e80ffd3 --- /dev/null +++ b/kivycharts.egg-info/PKG-INFO @@ -0,0 +1,14 @@ +Metadata-Version: 1.1 +Name: kivycharts +Version: 0.0.5 +Summary: kivy charts is a tool to build kivy data plot +Home-page: UNKNOWN +Author: yumoqing +Author-email: yumoqing@icloud.com +License: UNKNOWN +Description: UNKNOWN +Platform: any +Classifier: Development Status :: 3 - Alpha +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3.5 +Classifier: Topic :: Software Development :: Libraries :: Python Modules diff --git a/kivycharts.egg-info/SOURCES.txt b/kivycharts.egg-info/SOURCES.txt new file mode 100644 index 0000000..0297f40 --- /dev/null +++ b/kivycharts.egg-info/SOURCES.txt @@ -0,0 +1,37 @@ +README.md +setup.py +kivycharts/__init__.py +kivycharts/bar.py +kivycharts/chart.py +kivycharts/geometry.py +kivycharts/line.py +kivycharts/pie.py +kivycharts.egg-info/PKG-INFO +kivycharts.egg-info/SOURCES.txt +kivycharts.egg-info/dependency_links.txt +kivycharts.egg-info/requires.txt +kivycharts.egg-info/top_level.txt +kivycharts/graph/__init__.py +kivycharts/graph/_version.py +kivycharts/graph/arc.py +kivycharts/graph/base.py +kivycharts/graph/beziercurve.py +kivycharts/graph/circle.py +kivycharts/graph/compoundpath.py +kivycharts/graph/displayable.py +kivycharts/graph/droplet.py +kivycharts/graph/ellipse.py +kivycharts/graph/heart.py +kivycharts/graph/image.py +kivycharts/graph/line.py +kivycharts/graph/lsogon.py +kivycharts/graph/path.py +kivycharts/graph/plot.py +kivycharts/graph/polygon.py +kivycharts/graph/polyline.py +kivycharts/graph/rectangle.py +kivycharts/graph/rose.py +kivycharts/graph/sector.py +kivycharts/graph/star.py +kivycharts/graph/text.py +kivycharts/graph/touchoid.py \ No newline at end of file diff --git a/kivycharts.egg-info/dependency_links.txt b/kivycharts.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/kivycharts.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/kivycharts.egg-info/requires.txt b/kivycharts.egg-info/requires.txt new file mode 100644 index 0000000..612fb27 --- /dev/null +++ b/kivycharts.egg-info/requires.txt @@ -0,0 +1,10 @@ +jinja2 +kivy +ffpyplayer +qrcode +pillow +requests +plyer +python-osc +bs4 +lxml diff --git a/kivycharts.egg-info/top_level.txt b/kivycharts.egg-info/top_level.txt new file mode 100644 index 0000000..cdf55ae --- /dev/null +++ b/kivycharts.egg-info/top_level.txt @@ -0,0 +1 @@ +kivycharts diff --git a/kivycharts/.line.py.swp b/kivycharts/.line.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..294e303abeaf60219de02014d3140074ba2e40c6 GIT binary patch literal 12288 zcmeI2U5q4C6vyxSZXZi{A|9?GsqR(N(=)pm6GNC~3D&G(k+v$;Rkx?hR#%m(n%(Z* zY$QDSNQ8JH9t2M!;*AH9@E}1#;wzExM8X442#L@CIk!Ig3lE-1_2ieXzW3a7&;8$X zPu0wHr?vlvo8@wIN#J#s5c}SH*QU#_t0v2$s$b>Z9v@|?48 z|2N*Mv6TXq0+j-l0+j-l0+j-l0+j-l0{=k;(rBCb192Y*9st`i@K(Jl1u6w91u6w9 z1u6w91u6w91u6w91u6w91u6ypCkoiE5U*h^AJZQI@c)1M8^GIV3h@GX5Zns(fjRK= zb|KyaFM#L4!{APE2iOBz;B0UfI1~JQh7jL_ufdn#Bk&@47CZx<0#AYz+yyQN=Yd~N z7vgj9DtH>)4_vSgc7i3a9sG2f5Z{5%z+2!&@B}yl9MAz*fQ!I+;MY^(8+-v?1`h)l z+y(9gmx7DHcJM(>h}XeUa056IoB+N(MTmF6V;})-uoIjLzB*ZmH^58a8SoGo0vlWn z3~&bc<|Oz4&x1$7Bj6CQz@^}P@WY8hdylFu}Rt6!7B-LVOC| z2d{u9zyRC>_JDb?4SaGu_8Ytoo(2Q3A2h-5$b;Vi=fi(A$FH-UG>nJMSZ#Vqs^YqF z6TV)uM&CIxOj%qvgs9aL<#(H#u^siA11t5MgNbDao4%5(lDozlC+#<5&$UuDNE4V3 ze7mE3{s_BK$F}QQksqe@q;1f|P>SPG97?4grZhqzu>24Sj=c_TP&wSU-+%m(9Po3(E}@%U6^HDgKnqR~B; z%9w&Q8DB9OX=$mCFE5ruBn71h$&!uY%lWbg7Nh_nEflA|OCHN@?^h?i>0F?}$Nu{G@C zSMG^cC&Mm>BKaI898l_|zDhVGZneTF^}-;5=c8yHDV2~9Ew1XyFtA8_R??KADe2Sd z-Y`%NKFqO7w_>Cr#zz=McfK2aJ4xys-zwj&+F7uirG*8o%R61d$(0TxuF~|5ZXV1X z27i~7nsIM8l71Mby?OdNDPVK6;Osdcg}FYr>!%6#tF0c)C&PVXun%930k%;gtbi zlnFSng_vzoBlixS#PV6VzWV<+kkeyYn|Q}lYu5wo-x zE8C@`nwyn}Vaj+b^?W=@>qt4jO}Vz=9Z^%3i4%vuAK8H_{BCg5IyH?%+Z^+Wtk5Oi zqJ>&LHIS(3xXqLkG`sp2mpqK@_ZV#8cm|?Ejc5Cai%1_L{2q{d8BITuzdUFrZqk1O DYq*j4 literal 0 HcmV?d00001 diff --git a/kivycharts/graph/.base.py.swp b/kivycharts/graph/.base.py.swp new file mode 100644 index 0000000000000000000000000000000000000000..839f34b19ecb62c124a598336a063138f3285e42 GIT binary patch literal 57344 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 I**^6D08~tg: + canvas.before: + PushMatrix + Rotate: + angle: root.angle + axis: 0, 0, 1 + origin: root.center + canvas.after: + PopMatrix +""") + + +class GraphRotatedLabel(Label): + angle = NumericProperty(0) + + +class Axis(EventDispatcher): + pass + + +class XAxis(Axis): + pass + + +class YAxis(Axis): + pass + + +class Graph(Widget): + '''Graph class, see module documentation for more information. + ''' + + # triggers a full reload of graphics + _trigger = ObjectProperty(None) + # triggers only a repositioning of objects due to size/pos updates + _trigger_size = ObjectProperty(None) + # triggers only a update of colors, e.g. tick_color + _trigger_color = ObjectProperty(None) + # holds widget with the x-axis label + _xlabel = ObjectProperty(None) + # holds widget with the y-axis label + _ylabel = ObjectProperty(None) + # holds all the x-axis tick mark labels + _x_grid_label = ListProperty([]) + # holds all the y-axis tick mark labels + _y_grid_label = ListProperty([]) + # the mesh drawing all the ticks/grids + _mesh_ticks = ObjectProperty(None) + # the mesh which draws the surrounding rectangle + _mesh_rect = ObjectProperty(None) + # a list of locations of major and minor ticks. The values are not + # but is in the axis min - max range + _ticks_majorx = ListProperty([]) + _ticks_minorx = ListProperty([]) + _ticks_majory = ListProperty([]) + _ticks_minory = ListProperty([]) + + tick_color = ListProperty([.25, .25, .25, 1]) + '''Color of the grid/ticks, default to 1/4. grey. + ''' + + background_color = ListProperty([0, 0, 0, 0]) + '''Color of the background, defaults to transparent + ''' + + border_color = ListProperty([1, 1, 1, 1]) + '''Color of the border, defaults to white + ''' + + x_grid_texts = ListProperty(None) + label_options = DictProperty() + '''Label options that will be passed to `:class:`kivy.uix.Label`. + ''' + + _with_stencilbuffer = BooleanProperty(True) + '''Whether :class:`Graph`'s FBO should use FrameBuffer (True) or not + (False). + + .. warning:: This property is internal and so should be used with care. + It can break some other graphic instructions used by the :class:`Graph`, + for example you can have problems when drawing :class:`SmoothLinePlot` + plots, so use it only when you know what exactly you are doing. + + :data:`_with_stencilbuffer` is a :class:`~kivy.properties.BooleanProperty`, + defaults to True. + ''' + + def __init__(self, **kwargs): + super(Graph, self).__init__(**kwargs) + + with self.canvas: + self._fbo = Fbo( + size=self.size, with_stencilbuffer=self._with_stencilbuffer) + + with self._fbo: + self._background_color = Color(*self.background_color) + self._background_rect = Rectangle(size=self.size) + self._mesh_ticks_color = Color(*self.tick_color) + self._mesh_ticks = Mesh(mode='lines') + self._mesh_rect_color = Color(*self.border_color) + self._mesh_rect = Mesh(mode='line_strip') + + with self.canvas: + Color(1, 1, 1) + self._fbo_rect = Rectangle( + size=self.size, texture=self._fbo.texture) + + mesh = self._mesh_rect + mesh.vertices = [0] * (5 * 4) + mesh.indices = range(5) + + self._plot_area = StencilView() + self.add_widget(self._plot_area) + + t = self._trigger = Clock.create_trigger(self._redraw_all) + ts = self._trigger_size = Clock.create_trigger(self._redraw_size) + tc = self._trigger_color = Clock.create_trigger(self._update_colors) + + self.bind(center=ts, padding=ts, precision=ts, plots=ts, x_grid=ts, + y_grid=ts, draw_border=ts) + self.bind(xmin=t, xmax=t, xlog=t, x_ticks_major=t, x_ticks_minor=t, + xlabel=t, x_grid_label=t, ymin=t, ymax=t, ylog=t, + y_ticks_major=t, y_ticks_minor=t, ylabel=t, y_grid_label=t, + font_size=t, label_options=t, x_ticks_angle=t) + self.bind(tick_color=tc, background_color=tc, border_color=tc) + self._trigger() + + def add_widget(self, widget): + if widget is self._plot_area: + canvas = self.canvas + self.canvas = self._fbo + super(Graph, self).add_widget(widget) + if widget is self._plot_area: + self.canvas = canvas + + def remove_widget(self, widget): + if widget is self._plot_area: + canvas = self.canvas + self.canvas = self._fbo + super(Graph, self).remove_widget(widget) + if widget is self._plot_area: + self.canvas = canvas + + def _get_ticks(self, major, minor, log, s_min, s_max): + if major and s_max > s_min: + if log: + s_min = log10(s_min) + s_max = log10(s_max) + # count the decades in min - max. This is in actual decades, + # not logs. + n_decades = floor(s_max - s_min) + # for the fractional part of the last decade, we need to + # convert the log value, x, to 10**x but need to handle + # differently if the last incomplete decade has a decade + # boundary in it + if floor(s_min + n_decades) != floor(s_max): + n_decades += 1 - (10 ** (s_min + n_decades + 1) - 10 ** + s_max) / 10 ** floor(s_max + 1) + else: + n_decades += ((10 ** s_max - 10 ** (s_min + n_decades)) / + 10 ** floor(s_max + 1)) + # this might be larger than what is needed, but we delete + # excess later + n_ticks_major = n_decades / float(major) + n_ticks = int(floor(n_ticks_major * (minor if minor >= + 1. else 1.0))) + 2 + # in decade multiples, e.g. 0.1 of the decade, the distance + # between ticks + decade_dist = major / float(minor if minor else 1.0) + + points_minor = [0] * n_ticks + points_major = [0] * n_ticks + k = 0 # position in points major + k2 = 0 # position in points minor + # because each decade is missing 0.1 of the decade, if a tick + # falls in < min_pos skip it + min_pos = 0.1 - 0.00001 * decade_dist + s_min_low = floor(s_min) + # first real tick location. value is in fractions of decades + # from the start we have to use decimals here, otherwise + # floating point inaccuracies results in bad values + start_dec = ceil((10 ** Decimal(s_min - s_min_low - 1)) / + Decimal(decade_dist)) * decade_dist + count_min = (0 if not minor else + floor(start_dec / decade_dist) % minor) + start_dec += s_min_low + count = 0 # number of ticks we currently have passed start + while True: + # this is the current position in decade that we are. + # e.g. -0.9 means that we're at 0.1 of the 10**ceil(-0.9) + # decade + pos_dec = start_dec + decade_dist * count + pos_dec_low = floor(pos_dec) + diff = pos_dec - pos_dec_low + zero = abs(diff) < 0.001 * decade_dist + if zero: + # the same value as pos_dec but in log scale + pos_log = pos_dec_low + else: + pos_log = log10((pos_dec - pos_dec_low + ) * 10 ** ceil(pos_dec)) + if pos_log > s_max: + break + count += 1 + if zero or diff >= min_pos: + if minor and not count_min % minor: + points_major[k] = pos_log + k += 1 + else: + points_minor[k2] = pos_log + k2 += 1 + count_min += 1 + else: + # distance between each tick + tick_dist = major / float(minor if minor else 1.0) + n_ticks = int(floor((s_max - s_min) / tick_dist) + 1) + points_major = [0] * int(floor((s_max - s_min) / float(major)) + + 1) + points_minor = [0] * (n_ticks - len(points_major) + 1) + k = 0 # position in points major + k2 = 0 # position in points minor + for m in range(0, n_ticks): + if minor and m % minor: + if k2 >= len(points_minor): + continue + points_minor[k2] = m * tick_dist + s_min + k2 += 1 + else: + points_major[k] = m * tick_dist + s_min + k += 1 + del points_major[k:] + del points_minor[k2:] + else: + points_major = [] + points_minor = [] + return points_major, points_minor + + def _update_labels(self): + xlabel = self._xlabel + ylabel = self._ylabel + x = self.x + y = self.y + width = self.width + height = self.height + padding = self.padding + x_next = padding + x + y_next = padding + y + xextent = width + x + yextent = height + y + ymin = self.ymin + ymax = self.ymax + xmin = self.xmin + precision = self.precision + x_overlap = False + y_overlap = False + # set up x and y axis labels + if xlabel: + xlabel.text = self.xlabel + xlabel.texture_update() + xlabel.size = xlabel.texture_size + xlabel.pos = int( + x + width / 2. - xlabel.width / 2.), int(padding + y) + y_next += padding + xlabel.height + if ylabel: + ylabel.text = self.ylabel + ylabel.texture_update() + ylabel.size = ylabel.texture_size + ylabel.x = padding + x - (ylabel.width / 2. - ylabel.height / 2.) + x_next += padding + ylabel.height + xpoints = self._ticks_majorx + xlabels = self._x_grid_label + xlabel_grid = self.x_grid_label + ylabel_grid = self.y_grid_label + ypoints = self._ticks_majory + ylabels = self._y_grid_label + # now x and y tick mark labels + if len(ylabels) and ylabel_grid: + # horizontal size of the largest tick label, to have enough room + funcexp = exp10 if self.ylog else identity + funclog = log10 if self.ylog else identity + ylabels[0].text = precision % funcexp(ypoints[0]) + ylabels[0].texture_update() + y1 = ylabels[0].texture_size + y_start = y_next + (padding + y1[1] if len(xlabels) and xlabel_grid + else 0) + \ + (padding + y1[1] if not y_next else 0) + yextent = y + height - padding - y1[1] / 2. + + ymin = funclog(ymin) + ratio = (yextent - y_start) / float(funclog(ymax) - ymin) + y_start -= y1[1] / 2. + y1 = y1[0] + for k in range(len(ylabels)): + ylabels[k].text = precision % funcexp(ypoints[k]) + ylabels[k].texture_update() + ylabels[k].size = ylabels[k].texture_size + y1 = max(y1, ylabels[k].texture_size[0]) + ylabels[k].pos = ( + int(x_next), + int(y_start + (ypoints[k] - ymin) * ratio)) + if len(ylabels) > 1 and ylabels[0].top > ylabels[1].y: + y_overlap = True + else: + x_next += y1 + padding + if len(xlabels) and xlabel_grid: + funcexp = exp10 if self.xlog else identity + funclog = log10 if self.xlog else identity + # find the distance from the end that'll fit the last tick label + if self.x_grid_texts: + xlabels[0].text = self.x_grid_texts[int(xpoints[-1])] + else: + xlabels[0].text = precision % funcexp(xpoints[-1]) + xlabels[0].texture_update() + xextent = x + width - xlabels[0].texture_size[0] / 2. - padding + # find the distance from the start that'll fit the first tick label + if not x_next: + if self.x_grid_texts: + xlabels[0].text = self.x_grid_texts[int(xpoints[0])] + else: + xlabels[0].text = precision % funcexp(xpoints[0]) + xlabels[0].texture_update() + x_next = padding + xlabels[0].texture_size[0] / 2. + xmin = funclog(xmin) + ratio = (xextent - x_next) / float(funclog(self.xmax) - xmin) + right = -1 + for k in range(len(xlabels)): + if self.x_grid_texts: + xlabels[k].text = self.x_grid_texts[int(xpoints[k])] + else: + xlabels[k].text = precision % funcexp(xpoints[k]) + # update the size so we can center the labels on ticks + xlabels[k].texture_update() + xlabels[k].size = xlabels[k].texture_size + half_ts = xlabels[k].texture_size[0] / 2. + xlabels[k].pos = ( + int(x_next + (xpoints[k] - xmin) * ratio - half_ts), + int(y_next)) + if xlabels[k].x < right: + x_overlap = True + break + right = xlabels[k].right + if not x_overlap: + y_next += padding + xlabels[0].texture_size[1] + # now re-center the x and y axis labels + if xlabel: + xlabel.x = int( + x_next + (xextent - x_next) / 2. - xlabel.width / 2.) + if ylabel: + ylabel.y = int( + y_next + (yextent - y_next) / 2. - ylabel.height / 2.) + ylabel.angle = 90 + if x_overlap: + for k in range(len(xlabels)): + xlabels[k].text = '' + if y_overlap: + for k in range(len(ylabels)): + ylabels[k].text = '' + return x_next - x, y_next - y, xextent - x, yextent - y + + def _update_ticks(self, size): + # re-compute the positions of the bounding rectangle + mesh = self._mesh_rect + vert = mesh.vertices + if self.draw_border: + s0, s1, s2, s3 = size + vert[0] = s0 + vert[1] = s1 + vert[4] = s2 + vert[5] = s1 + vert[8] = s2 + vert[9] = s3 + vert[12] = s0 + vert[13] = s3 + vert[16] = s0 + vert[17] = s1 + else: + vert[0:18] = [0 for k in range(18)] + mesh.vertices = vert + # re-compute the positions of the x/y axis ticks + mesh = self._mesh_ticks + vert = mesh.vertices + start = 0 + xpoints = self._ticks_majorx + ypoints = self._ticks_majory + xpoints2 = self._ticks_minorx + ypoints2 = self._ticks_minory + ylog = self.ylog + xlog = self.xlog + xmin = self.xmin + xmax = self.xmax + if xlog: + xmin = log10(xmin) + xmax = log10(xmax) + ymin = self.ymin + ymax = self.ymax + if ylog: + ymin = log10(ymin) + ymax = log10(ymax) + if len(xpoints): + top = size[3] if self.x_grid else metrics.dp(12) + size[1] + ratio = (size[2] - size[0]) / float(xmax - xmin) + for k in range(start, len(xpoints) + start): + vert[k * 8] = size[0] + (xpoints[k - start] - xmin) * ratio + vert[k * 8 + 1] = size[1] + vert[k * 8 + 4] = vert[k * 8] + vert[k * 8 + 5] = top + start += len(xpoints) + if len(xpoints2): + top = metrics.dp(8) + size[1] + ratio = (size[2] - size[0]) / float(xmax - xmin) + for k in range(start, len(xpoints2) + start): + vert[k * 8] = size[0] + (xpoints2[k - start] - xmin) * ratio + vert[k * 8 + 1] = size[1] + vert[k * 8 + 4] = vert[k * 8] + vert[k * 8 + 5] = top + start += len(xpoints2) + if len(ypoints): + top = size[2] if self.y_grid else metrics.dp(12) + size[0] + ratio = (size[3] - size[1]) / float(ymax - ymin) + for k in range(start, len(ypoints) + start): + vert[k * 8 + 1] = size[1] + (ypoints[k - start] - ymin) * ratio + vert[k * 8 + 5] = vert[k * 8 + 1] + vert[k * 8] = size[0] + vert[k * 8 + 4] = top + start += len(ypoints) + if len(ypoints2): + top = metrics.dp(8) + size[0] + ratio = (size[3] - size[1]) / float(ymax - ymin) + for k in range(start, len(ypoints2) + start): + vert[k * 8 + 1] = size[1] + ( + ypoints2[k - start] - ymin) * ratio + vert[k * 8 + 5] = vert[k * 8 + 1] + vert[k * 8] = size[0] + vert[k * 8 + 4] = top + mesh.vertices = vert + + x_axis = ListProperty([None]) + y_axis = ListProperty([None]) + + def get_x_axis(self, axis=0): + if axis == 0: + return self.xlog, self.xmin, self.xmax + info = self.x_axis[axis] + return info["log"], info["min"], info["max"] + + def get_y_axis(self, axis=0): + if axis == 0: + return self.ylog, self.ymin, self.ymax + info = self.y_axis[axis] + return info["log"], info["min"], info["max"] + + def add_x_axis(self, xmin, xmax, xlog=False): + data = { + "log": xlog, + "min": xmin, + "max": xmax + } + self.x_axis.append(data) + return data + + def add_y_axis(self, ymin, ymax, ylog=False): + data = { + "log": ylog, + "min": ymin, + "max": ymax + } + self.y_axis.append(data) + return data + + def _update_plots(self, size): + for plot in self.plots: + xlog, xmin, xmax = self.get_x_axis(plot.x_axis) + ylog, ymin, ymax = self.get_y_axis(plot.y_axis) + plot._update(xlog, xmin, xmax, ylog, ymin, ymax, size) + + def _update_colors(self, *args): + self._mesh_ticks_color.rgba = tuple(self.tick_color) + self._background_color.rgba = tuple(self.background_color) + self._mesh_rect_color.rgba = tuple(self.border_color) + + def _redraw_all(self, *args): + # add/remove all the required labels + xpoints_major, xpoints_minor = self._redraw_x(*args) + ypoints_major, ypoints_minor = self._redraw_y(*args) + + mesh = self._mesh_ticks + n_points = (len(xpoints_major) + len(xpoints_minor) + + len(ypoints_major) + len(ypoints_minor)) + mesh.vertices = [0] * (n_points * 8) + mesh.indices = [k for k in range(n_points * 2)] + self._redraw_size() + + def _redraw_x(self, *args): + font_size = self.font_size + if self.xlabel: + xlabel = self._xlabel + if not xlabel: + xlabel = Label() + self.add_widget(xlabel) + self._xlabel = xlabel + + xlabel.font_size = font_size + for k, v in self.label_options.items(): + setattr(xlabel, k, v) + + else: + xlabel = self._xlabel + if xlabel: + self.remove_widget(xlabel) + self._xlabel = None + grids = self._x_grid_label + xpoints_major, xpoints_minor = self._get_ticks(self.x_ticks_major, + self.x_ticks_minor, + self.xlog, self.xmin, + self.xmax) + self._ticks_majorx = xpoints_major + self._ticks_minorx = xpoints_minor + + if not self.x_grid_label: + n_labels = 0 + else: + n_labels = len(xpoints_major) + + for k in range(n_labels, len(grids)): + self.remove_widget(grids[k]) + del grids[n_labels:] + + grid_len = len(grids) + grids.extend([None] * (n_labels - len(grids))) + for k in range(grid_len, n_labels): + grids[k] = GraphRotatedLabel( + font_size=font_size, angle=self.x_ticks_angle, + **self.label_options) + self.add_widget(grids[k]) + return xpoints_major, xpoints_minor + + def _redraw_y(self, *args): + font_size = self.font_size + if self.ylabel: + ylabel = self._ylabel + if not ylabel: + ylabel = GraphRotatedLabel() + self.add_widget(ylabel) + self._ylabel = ylabel + + ylabel.font_size = font_size + for k, v in self.label_options.items(): + setattr(ylabel, k, v) + else: + ylabel = self._ylabel + if ylabel: + self.remove_widget(ylabel) + self._ylabel = None + grids = self._y_grid_label + ypoints_major, ypoints_minor = self._get_ticks(self.y_ticks_major, + self.y_ticks_minor, + self.ylog, self.ymin, + self.ymax) + self._ticks_majory = ypoints_major + self._ticks_minory = ypoints_minor + + if not self.y_grid_label: + n_labels = 0 + else: + n_labels = len(ypoints_major) + + for k in range(n_labels, len(grids)): + self.remove_widget(grids[k]) + del grids[n_labels:] + + grid_len = len(grids) + grids.extend([None] * (n_labels - len(grids))) + for k in range(grid_len, n_labels): + grids[k] = Label(font_size=font_size, **self.label_options) + self.add_widget(grids[k]) + return ypoints_major, ypoints_minor + + def _redraw_size(self, *args): + # size a 4-tuple describing the bounding box in which we can draw + # graphs, it's (x0, y0, x1, y1), which correspond with the bottom left + # and top right corner locations, respectively + self._clear_buffer() + size = self._update_labels() + self.view_pos = self._plot_area.pos = (size[0], size[1]) + self.view_size = self._plot_area.size = ( + size[2] - size[0], size[3] - size[1]) + + if self.size[0] and self.size[1]: + self._fbo.size = self.size + else: + self._fbo.size = 1, 1 # gl errors otherwise + self._fbo_rect.texture = self._fbo.texture + self._fbo_rect.size = self.size + self._fbo_rect.pos = self.pos + self._background_rect.size = self.size + self._update_ticks(size) + self._update_plots(size) + + def _clear_buffer(self, *largs): + fbo = self._fbo + fbo.bind() + fbo.clear_buffer() + fbo.release() + + def add_plot(self, plot): + '''Add a new plot to this graph. + + :Parameters: + `plot`: + Plot to add to this graph. + + >>> graph = Graph() + >>> plot = MeshLinePlot(mode='line_strip', color=[1, 0, 0, 1]) + >>> plot.points = [(x / 10., sin(x / 50.)) for x in range(-0, 101)] + >>> graph.add_plot(plot) + ''' + if plot in self.plots: + return + add = self._plot_area.canvas.add + for instr in plot.get_drawings(): + add(instr) + plot.bind(on_clear_plot=self._clear_buffer) + self.plots.append(plot) + + def remove_plot(self, plot): + '''Remove a plot from this graph. + + :Parameters: + `plot`: + Plot to remove from this graph. + + >>> graph = Graph() + >>> plot = MeshLinePlot(mode='line_strip', color=[1, 0, 0, 1]) + >>> plot.points = [(x / 10., sin(x / 50.)) for x in range(-0, 101)] + >>> graph.add_plot(plot) + >>> graph.remove_plot(plot) + ''' + if plot not in self.plots: + return + remove = self._plot_area.canvas.remove + for instr in plot.get_drawings(): + remove(instr) + plot.unbind(on_clear_plot=self._clear_buffer) + self.plots.remove(plot) + self._clear_buffer() + + def collide_plot(self, x, y): + '''Determine if the given coordinates fall inside the plot area. Use + `x, y = self.to_widget(x, y, relative=True)` to first convert into + widget coordinates if it's in window coordinates because it's assumed + to be given in local widget coordinates, relative to the graph's pos. + + :Parameters: + `x, y`: + The coordinates to test. + ''' + adj_x, adj_y = x - self._plot_area.pos[0], y - self._plot_area.pos[1] + return 0 <= adj_x <= self._plot_area.size[0] \ + and 0 <= adj_y <= self._plot_area.size[1] + + def to_data(self, x, y): + '''Convert widget coords to data coords. Use + `x, y = self.to_widget(x, y, relative=True)` to first convert into + widget coordinates if it's in window coordinates because it's assumed + to be given in local widget coordinates, relative to the graph's pos. + + :Parameters: + `x, y`: + The coordinates to convert. + + If the graph has multiple axes, use :class:`Plot.unproject` instead. + ''' + adj_x = float(x - self._plot_area.pos[0]) + adj_y = float(y - self._plot_area.pos[1]) + norm_x = adj_x / self._plot_area.size[0] + norm_y = adj_y / self._plot_area.size[1] + if self.xlog: + xmin, xmax = log10(self.xmin), log10(self.xmax) + conv_x = 10.**(norm_x * (xmax - xmin) + xmin) + else: + conv_x = norm_x * (self.xmax - self.xmin) + self.xmin + if self.ylog: + ymin, ymax = log10(self.ymin), log10(self.ymax) + conv_y = 10.**(norm_y * (ymax - ymin) + ymin) + else: + conv_y = norm_y * (self.ymax - self.ymin) + self.ymin + return [conv_x, conv_y] + + xmin = NumericProperty(0.) + '''The x-axis minimum value. + + If :data:`xlog` is True, xmin must be larger than zero. + + :data:`xmin` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + xmax = NumericProperty(100.) + '''The x-axis maximum value, larger than xmin. + + :data:`xmax` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + xlog = BooleanProperty(False) + '''Determines whether the x-axis should be displayed logarithmically (True) + or linearly (False). + + :data:`xlog` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + x_ticks_major = BoundedNumericProperty(0, min=0) + '''Distance between major tick marks on the x-axis. + + Determines the distance between the major tick marks. Major tick marks + start from min and re-occur at every ticks_major until :data:`xmax`. + If :data:`xmax` doesn't overlap with a integer multiple of ticks_major, + no tick will occur at :data:`xmax`. Zero indicates no tick marks. + + If :data:`xlog` is true, then this indicates the distance between ticks + in multiples of current decade. E.g. if :data:`xmin` is 0.1 and + ticks_major is 0.1, it means there will be a tick at every 10th of the + decade, i.e. 0.1 ... 0.9, 1, 2... If it is 0.3, the ticks will occur at + 0.1, 0.3, 0.6, 0.9, 2, 5, 8, 10. You'll notice that it went from 8 to 10 + instead of to 20, that's so that we can say 0.5 and have ticks at every + half decade, e.g. 0.1, 0.5, 1, 5, 10, 50... Similarly, if ticks_major is + 1.5, there will be ticks at 0.1, 5, 100, 5,000... Also notice, that there's + always a major tick at the start. Finally, if e.g. :data:`xmin` is 0.6 + and this 0.5 there will be ticks at 0.6, 1, 5... + + :data:`x_ticks_major` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + x_ticks_minor = BoundedNumericProperty(0, min=0) + '''The number of sub-intervals that divide x_ticks_major. + + Determines the number of sub-intervals into which ticks_major is divided, + if non-zero. The actual number of minor ticks between the major ticks is + ticks_minor - 1. Only used if ticks_major is non-zero. If there's no major + tick at xmax then the number of minor ticks after the last major + tick will be however many ticks fit until xmax. + + If self.xlog is true, then this indicates the number of intervals the + distance between major ticks is divided. The result is the number of + multiples of decades between ticks. I.e. if ticks_minor is 10, then if + ticks_major is 1, there will be ticks at 0.1, 0.2...0.9, 1, 2, 3... If + ticks_major is 0.3, ticks will occur at 0.1, 0.12, 0.15, 0.18... Finally, + as is common, if ticks major is 1, and ticks minor is 5, there will be + ticks at 0.1, 0.2, 0.4... 0.8, 1, 2... + + :data:`x_ticks_minor` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + x_grid = BooleanProperty(False) + '''Determines whether the x-axis has tick marks or a full grid. + + If :data:`x_ticks_major` is non-zero, then if x_grid is False tick marks + will be displayed at every major tick. If x_grid is True, instead of ticks, + a vertical line will be displayed at every major tick. + + :data:`x_grid` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + x_grid_label = BooleanProperty(False) + '''Whether labels should be displayed beneath each major tick. If true, + each major tick will have a label containing the axis value. + + :data:`x_grid_label` is a :class:`~kivy.properties.BooleanProperty`, + defaults to False. + ''' + + xlabel = StringProperty('') + '''The label for the x-axis. If not empty it is displayed in the center of + the axis. + + :data:`xlabel` is a :class:`~kivy.properties.StringProperty`, + defaults to ''. + ''' + + ymin = NumericProperty(0.) + '''The y-axis minimum value. + + If :data:`ylog` is True, ymin must be larger than zero. + + :data:`ymin` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + ymax = NumericProperty(100.) + '''The y-axis maximum value, larger than ymin. + + :data:`ymax` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' + + ylog = BooleanProperty(False) + '''Determines whether the y-axis should be displayed logarithmically (True) + or linearly (False). + + :data:`ylog` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + y_ticks_major = BoundedNumericProperty(0, min=0) + '''Distance between major tick marks. See :data:`x_ticks_major`. + + :data:`y_ticks_major` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + y_ticks_minor = BoundedNumericProperty(0, min=0) + '''The number of sub-intervals that divide ticks_major. + See :data:`x_ticks_minor`. + + :data:`y_ticks_minor` is a + :class:`~kivy.properties.BoundedNumericProperty`, defaults to 0. + ''' + + y_grid = BooleanProperty(False) + '''Determines whether the y-axis has tick marks or a full grid. See + :data:`x_grid`. + + :data:`y_grid` is a :class:`~kivy.properties.BooleanProperty`, defaults + to False. + ''' + + y_grid_label = BooleanProperty(False) + '''Whether labels should be displayed beneath each major tick. If true, + each major tick will have a label containing the axis value. + + :data:`y_grid_label` is a :class:`~kivy.properties.BooleanProperty`, + defaults to False. + ''' + + ylabel = StringProperty('') + '''The label for the y-axis. If not empty it is displayed in the center of + the axis. + + :data:`ylabel` is a :class:`~kivy.properties.StringProperty`, + defaults to ''. + ''' + + padding = NumericProperty('5dp') + '''Padding distances between the labels, axes titles and graph, as + well between the widget and the objects near the boundaries. + + :data:`padding` is a :class:`~kivy.properties.NumericProperty`, defaults + to 5dp. + ''' + + font_size = NumericProperty('15sp') + '''Font size of the labels. + + :data:`font_size` is a :class:`~kivy.properties.NumericProperty`, defaults + to 15sp. + ''' + + x_ticks_angle = NumericProperty(0) + '''Rotate angle of the x-axis tick marks. + + :data:`x_ticks_angle` is a :class:`~kivy.properties.NumericProperty`, + defaults to 0. + ''' + + precision = StringProperty('%g') + '''Determines the numerical precision of the tick mark labels. This value + governs how the numbers are converted into string representation. Accepted + values are those listed in Python's manual in the + "String Formatting Operations" section. + + :data:`precision` is a :class:`~kivy.properties.StringProperty`, defaults + to '%g'. + ''' + + draw_border = BooleanProperty(True) + '''Whether a border is drawn around the canvas of the graph where the + plots are displayed. + + :data:`draw_border` is a :class:`~kivy.properties.BooleanProperty`, + defaults to True. + ''' + + plots = ListProperty([]) + '''Holds a list of all the plots in the graph. To add and remove plots + from the graph use :data:`add_plot` and :data:`add_plot`. Do not add + directly edit this list. + + :data:`plots` is a :class:`~kivy.properties.ListProperty`, + defaults to []. + ''' + + view_size = ObjectProperty((0, 0)) + '''The size of the graph viewing area - the area where the plots are + displayed, excluding labels etc. + ''' + + view_pos = ObjectProperty((0, 0)) + '''The pos of the graph viewing area - the area where the plots are + displayed, excluding labels etc. It is relative to the graph's pos. + ''' + + diff --git a/kivycharts/graph/plot.py b/kivycharts/graph/plot.py new file mode 100644 index 0000000..62d5121 --- /dev/null +++ b/kivycharts/graph/plot.py @@ -0,0 +1,795 @@ + +from kivy.uix.widget import Widget +from kivy.uix.label import Label +from kivy.uix.stencilview import StencilView +from kivy.properties import NumericProperty, BooleanProperty,\ + BoundedNumericProperty, StringProperty, ListProperty, ObjectProperty,\ + DictProperty, AliasProperty +from kivy.clock import Clock +from kivy.graphics import Mesh, Color, Rectangle, Point +from kivy.graphics import Fbo +from kivy.graphics.texture import Texture +from kivy.event import EventDispatcher +from kivy.lang import Builder +from kivy.logger import Logger +from kivy import metrics +from math import log10, floor, ceil +from decimal import Decimal +from itertools import chain +try: + import numpy as np +except ImportError as e: + np = None + +class Plot(EventDispatcher): + '''Plot class, see module documentation for more information. + + :Events: + `on_clear_plot` + Fired before a plot updates the display and lets the fbo know that + it should clear the old drawings. + + ..versionadded:: 0.4 + ''' + + __events__ = ('on_clear_plot', ) + + # most recent values of the params used to draw the plot + params = DictProperty({'xlog': False, 'xmin': 0, 'xmax': 100, + 'ylog': False, 'ymin': 0, 'ymax': 100, + 'size': (0, 0, 0, 0)}) + + color = ListProperty([1, 1, 1, 1]) + '''Color of the plot. + ''' + + points = ListProperty([]) + '''List of (x, y) points to be displayed in the plot. + + The elements of points are 2-tuples, (x, y). The points are displayed + based on the mode setting. + + :data:`points` is a :class:`~kivy.properties.ListProperty`, defaults to + []. + ''' + + x_axis = NumericProperty(0) + '''Index of the X axis to use, defaults to 0 + ''' + + y_axis = NumericProperty(0) + '''Index of the Y axis to use, defaults to 0 + ''' + + def __init__(self, **kwargs): + super(Plot, self).__init__(**kwargs) + self.ask_draw = Clock.create_trigger(self.draw) + self.bind(params=self.ask_draw, points=self.ask_draw) + self._drawings = self.create_drawings() + + def funcx(self): + """Return a function that convert or not the X value according to plot + prameters""" + return log10 if self.params["xlog"] else lambda x: x + + def funcy(self): + """Return a function that convert or not the Y value according to plot + prameters""" + return log10 if self.params["ylog"] else lambda y: y + + def x_px(self): + """Return a function that convert the X value of the graph to the + pixel coordinate on the plot, according to the plot settings and axis + settings. It's relative to the graph pos. + """ + funcx = self.funcx() + params = self.params + size = params["size"] + xmin = funcx(params["xmin"]) + xmax = funcx(params["xmax"]) + ratiox = (size[2] - size[0]) / float(xmax - xmin) + return lambda x: (funcx(x) - xmin) * ratiox + size[0] + + def y_px(self): + """Return a function that convert the Y value of the graph to the + pixel coordinate on the plot, according to the plot settings and axis + settings. The returned value is relative to the graph pos. + """ + funcy = self.funcy() + params = self.params + size = params["size"] + ymin = funcy(params["ymin"]) + ymax = funcy(params["ymax"]) + ratioy = (size[3] - size[1]) / float(ymax - ymin) + return lambda y: (funcy(y) - ymin) * ratioy + size[1] + + def unproject(self, x, y): + """Return a function that unproject a pixel to a X/Y value on the plot + (works only for linear, not log yet). `x`, `y`, is relative to the + graph pos, so the graph's pos needs to be subtracted from x, y before + passing it in. + """ + params = self.params + size = params["size"] + xmin = params["xmin"] + xmax = params["xmax"] + ymin = params["ymin"] + ymax = params["ymax"] + ratiox = (size[2] - size[0]) / float(xmax - xmin) + ratioy = (size[3] - size[1]) / float(ymax - ymin) + x0 = (x - size[0]) / ratiox + xmin + y0 = (y - size[1]) / ratioy + ymin + return x0, y0 + + def get_px_bounds(self): + """Returns a dict containing the pixels bounds from the plot parameters. + The returned values are relative to the graph pos. + """ + params = self.params + x_px = self.x_px() + y_px = self.y_px() + return { + "xmin": x_px(params["xmin"]), + "xmax": x_px(params["xmax"]), + "ymin": y_px(params["ymin"]), + "ymax": y_px(params["ymax"]), + } + + def update(self, xlog, xmin, xmax, ylog, ymin, ymax, size): + '''Called by graph whenever any of the parameters + change. The plot should be recalculated then. + log, min, max indicate the axis settings. + size a 4-tuple describing the bounding box in which we can draw + graphs, it's (x0, y0, x1, y1), which correspond with the bottom left + and top right corner locations, respectively. + ''' + self.params.update({ + 'xlog': xlog, 'xmin': xmin, 'xmax': xmax, 'ylog': ylog, + 'ymin': ymin, 'ymax': ymax, 'size': size}) + + def get_group(self): + '''returns a string which is unique and is the group name given to all + the instructions returned by _get_drawings. Graph uses this to remove + these instructions when needed. + ''' + return '' + + def get_drawings(self): + '''returns a list of canvas instructions that will be added to the + graph's canvas. + ''' + if isinstance(self._drawings, (tuple, list)): + return self._drawings + return [] + + def create_drawings(self): + '''called once to create all the canvas instructions needed for the + plot + ''' + pass + + def draw(self, *largs): + '''draw the plot according to the params. It dispatches on_clear_plot + so derived classes should call super before updating. + ''' + self.dispatch('on_clear_plot') + + def iterate_points(self): + '''Iterate on all the points adjusted to the graph settings + ''' + x_px = self.x_px() + y_px = self.y_px() + for x, y in self.points: + yield x_px(x), y_px(y) + + def on_clear_plot(self, *largs): + pass + + # compatibility layer + _update = update + _get_drawings = get_drawings + _params = params + + +class MeshLinePlot(Plot): + '''MeshLinePlot class which displays a set of points similar to a mesh. + ''' + + def _set_mode(self, value): + if hasattr(self, '_mesh'): + self._mesh.mode = value + + mode = AliasProperty(lambda self: self._mesh.mode, _set_mode) + '''VBO Mode used for drawing the points. Can be one of: 'points', + 'line_strip', 'line_loop', 'lines', 'triangle_strip', 'triangle_fan'. + See :class:`~kivy.graphics.Mesh` for more details. + + Defaults to 'line_strip'. + ''' + + def create_drawings(self): + self._color = Color(*self.color) + self._mesh = Mesh(mode='line_strip') + self.bind( + color=lambda instr, value: setattr(self._color, "rgba", value)) + return [self._color, self._mesh] + + def draw(self, *args): + super(MeshLinePlot, self).draw(*args) + self.plot_mesh() + + def plot_mesh(self): + points = [p for p in self.iterate_points()] + mesh, vert, _ = self.set_mesh_size(len(points)) + for k, (x, y) in enumerate(points): + vert[k * 4] = x + vert[k * 4 + 1] = y + mesh.vertices = vert + + def set_mesh_size(self, size): + mesh = self._mesh + vert = mesh.vertices + ind = mesh.indices + diff = size - len(vert) // 4 + if diff < 0: + del vert[4 * size:] + del ind[size:] + elif diff > 0: + ind.extend(range(len(ind), len(ind) + diff)) + vert.extend([0] * (diff * 4)) + mesh.vertices = vert + return mesh, vert, ind + + +class MeshStemPlot(MeshLinePlot): + '''MeshStemPlot uses the MeshLinePlot class to draw a stem plot. The data + provided is graphed from origin to the data point. + ''' + + def plot_mesh(self): + points = [p for p in self.iterate_points()] + mesh, vert, _ = self.set_mesh_size(len(points) * 2) + y0 = self.y_px()(0) + for k, (x, y) in enumerate(self.iterate_points()): + vert[k * 8] = x + vert[k * 8 + 1] = y0 + vert[k * 8 + 4] = x + vert[k * 8 + 5] = y + mesh.vertices = vert + + +class LinePlot(Plot): + """LinePlot draws using a standard Line object. + """ + + line_width = NumericProperty(1) + + def create_drawings(self): + from kivy.graphics import Line, RenderContext + + self._grc = RenderContext( + use_parent_modelview=True, + use_parent_projection=True) + with self._grc: + self._gcolor = Color(*self.color) + self._gline = Line( + points=[], cap='none', + width=self.line_width, joint='round') + + return [self._grc] + + def draw(self, *args): + super(LinePlot, self).draw(*args) + # flatten the list + points = [] + for x, y in self.iterate_points(): + points += [x, y] + self._gline.points = points + + def on_line_width(self, *largs): + if hasattr(self, "_gline"): + self._gline.width = self.line_width + + +class SmoothLinePlot(Plot): + '''Smooth Plot class, see module documentation for more information. + This plot use a specific Fragment shader for a custom anti aliasing. + ''' + + SMOOTH_FS = ''' + $HEADER$ + + void main(void) { + float edgewidth = 0.015625 * 64.; + float t = texture2D(texture0, tex_coord0).r; + float e = smoothstep(0., edgewidth, t); + gl_FragColor = frag_color * vec4(1, 1, 1, e); + } + ''' + + # XXX This gradient data is a 64x1 RGB image, and + # values goes from 0 -> 255 -> 0. + GRADIENT_DATA = ( + b"\x00\x00\x00\x07\x07\x07\x0f\x0f\x0f\x17\x17\x17\x1f\x1f\x1f" + b"'''///777???GGGOOOWWW___gggooowww\x7f\x7f\x7f\x87\x87\x87" + b"\x8f\x8f\x8f\x97\x97\x97\x9f\x9f\x9f\xa7\xa7\xa7\xaf\xaf\xaf" + b"\xb7\xb7\xb7\xbf\xbf\xbf\xc7\xc7\xc7\xcf\xcf\xcf\xd7\xd7\xd7" + b"\xdf\xdf\xdf\xe7\xe7\xe7\xef\xef\xef\xf7\xf7\xf7\xff\xff\xff" + b"\xf6\xf6\xf6\xee\xee\xee\xe6\xe6\xe6\xde\xde\xde\xd5\xd5\xd5" + b"\xcd\xcd\xcd\xc5\xc5\xc5\xbd\xbd\xbd\xb4\xb4\xb4\xac\xac\xac" + b"\xa4\xa4\xa4\x9c\x9c\x9c\x94\x94\x94\x8b\x8b\x8b\x83\x83\x83" + b"{{{sssjjjbbbZZZRRRJJJAAA999111))) \x18\x18\x18\x10\x10\x10" + b"\x08\x08\x08\x00\x00\x00") + + def create_drawings(self): + from kivy.graphics import Line, RenderContext + + # very first time, create a texture for the shader + if not hasattr(SmoothLinePlot, '_texture'): + tex = Texture.create(size=(1, 64), colorfmt='rgb') + tex.add_reload_observer(SmoothLinePlot._smooth_reload_observer) + SmoothLinePlot._texture = tex + SmoothLinePlot._smooth_reload_observer(tex) + + self._grc = RenderContext( + fs=SmoothLinePlot.SMOOTH_FS, + use_parent_modelview=True, + use_parent_projection=True) + with self._grc: + self._gcolor = Color(*self.color) + self._gline = Line( + points=[], cap='none', width=2., + texture=SmoothLinePlot._texture) + + return [self._grc] + + @staticmethod + def _smooth_reload_observer(texture): + texture.blit_buffer(SmoothLinePlot.GRADIENT_DATA, colorfmt="rgb") + + def draw(self, *args): + super(SmoothLinePlot, self).draw(*args) + # flatten the list + points = [] + for x, y in self.iterate_points(): + points += [x, y] + self._gline.points = points + + +class ContourPlot(Plot): + """ + ContourPlot visualizes 3 dimensional data as an intensity map image. + The user must first specify 'xrange' and 'yrange' (tuples of min,max) and + then 'data', the intensity values. + `data`, is a MxN matrix, where the first dimension of size M specifies the + `y` values, and the second dimension of size N specifies the `x` values. + Axis Y and X values are assumed to be linearly spaced values from + xrange/yrange and the dimensions of 'data', `MxN`, respectively. + The color values are automatically scaled to the min and max z range of the + data set. + """ + _image = ObjectProperty(None) + data = ObjectProperty(None, force_dispatch=True) + xrange = ListProperty([0, 100]) + yrange = ListProperty([0, 100]) + + def __init__(self, **kwargs): + super(ContourPlot, self).__init__(**kwargs) + self.bind(data=self.ask_draw, xrange=self.ask_draw, + yrange=self.ask_draw) + + def create_drawings(self): + self._image = Rectangle() + self._color = Color([1, 1, 1, 1]) + self.bind( + color=lambda instr, value: setattr(self._color, 'rgba', value)) + return [self._color, self._image] + + def draw(self, *args): + super(ContourPlot, self).draw(*args) + data = self.data + xdim, ydim = data.shape + + # Find the minimum and maximum z values + zmax = data.max() + zmin = data.min() + rgb_scale_factor = 1.0 / (zmax - zmin) * 255 + # Scale the z values into RGB data + buf = np.array(data, dtype=float, copy=True) + np.subtract(buf, zmin, out=buf) + np.multiply(buf, rgb_scale_factor, out=buf) + # Duplicate into 3 dimensions (RGB) and convert to byte array + buf = np.asarray(buf, dtype=np.uint8) + buf = np.expand_dims(buf, axis=2) + buf = np.concatenate((buf, buf, buf), axis=2) + buf = np.reshape(buf, (xdim, ydim, 3)) + + charbuf = bytearray(np.reshape(buf, (buf.size))) + self._texture = Texture.create(size=(xdim, ydim), colorfmt='rgb') + self._texture.blit_buffer(charbuf, colorfmt='rgb', bufferfmt='ubyte') + image = self._image + image.texture = self._texture + + x_px = self.x_px() + y_px = self.y_px() + bl = x_px(self.xrange[0]), y_px(self.yrange[0]) + tr = x_px(self.xrange[1]), y_px(self.yrange[1]) + image.pos = bl + w = tr[0] - bl[0] + h = tr[1] - bl[1] + image.size = (w, h) + + +class BarPlot(Plot): + '''BarPlot class which displays a bar graph. + ''' + + bar_width = NumericProperty(1) + bar_spacing = NumericProperty(1.) + graph = ObjectProperty(allownone=True) + + def __init__(self, *ar, **kw): + super(BarPlot, self).__init__(*ar, **kw) + self.bind(bar_width=self.ask_draw) + self.bind(points=self.update_bar_width) + self.bind(graph=self.update_bar_width) + + def update_bar_width(self, *ar): + if not self.graph: + return + if len(self.points) < 2: + return + if self.graph.xmax == self.graph.xmin: + return + + point_width = ( + len(self.points) * + float(abs(self.graph.xmax) + abs(self.graph.xmin)) / + float(abs(max(self.points)[0]) + abs(min(self.points)[0]))) + + if not self.points: + self.bar_width = 1 + else: + self.bar_width = ( + (self.graph.width - self.graph.padding) / + point_width * self.bar_spacing) + + def create_drawings(self): + self._color = Color(*self.color) + self._mesh = Mesh() + self.bind( + color=lambda instr, value: setattr(self._color, 'rgba', value)) + return [self._color, self._mesh] + + def draw(self, *args): + super(BarPlot, self).draw(*args) + points = self.points + + # The mesh only supports (2^16) - 1 indices, so... + if len(points) * 6 > 65535: + Logger.error( + "BarPlot: cannot support more than 10922 points. " + "Ignoring extra points.") + points = points[:10922] + + point_len = len(points) + mesh = self._mesh + mesh.mode = 'triangles' + vert = mesh.vertices + ind = mesh.indices + diff = len(points) * 6 - len(vert) // 4 + if diff < 0: + del vert[24 * point_len:] + del ind[point_len:] + elif diff > 0: + ind.extend(range(len(ind), len(ind) + diff)) + vert.extend([0] * (diff * 4)) + + bounds = self.get_px_bounds() + x_px = self.x_px() + y_px = self.y_px() + ymin = y_px(0) + + bar_width = self.bar_width + if bar_width < 0: + bar_width = x_px(bar_width) - bounds["xmin"] + + for k in range(point_len): + p = points[k] + x1 = x_px(p[0]) + x2 = x1 + bar_width + y1 = ymin + y2 = y_px(p[1]) + + idx = k * 24 + # first triangle + vert[idx] = x1 + vert[idx + 1] = y2 + vert[idx + 4] = x1 + vert[idx + 5] = y1 + vert[idx + 8] = x2 + vert[idx + 9] = y1 + # second triangle + vert[idx + 12] = x1 + vert[idx + 13] = y2 + vert[idx + 16] = x2 + vert[idx + 17] = y2 + vert[idx + 20] = x2 + vert[idx + 21] = y1 + mesh.vertices = vert + + def _unbind_graph(self, graph): + graph.unbind(width=self.update_bar_width, + xmin=self.update_bar_width, + ymin=self.update_bar_width) + + def bind_to_graph(self, graph): + old_graph = self.graph + + if old_graph: + # unbind from the old one + self._unbind_graph(old_graph) + + # bind to the new one + self.graph = graph + graph.bind(width=self.update_bar_width, + xmin=self.update_bar_width, + ymin=self.update_bar_width) + + def unbind_from_graph(self): + if self.graph: + self._unbind_graph(self.graph) + + +class HBar(MeshLinePlot): + '''HBar draw horizontal bar on all the Y points provided + ''' + + def plot_mesh(self, *args): + points = self.points + mesh, vert, ind = self.set_mesh_size(len(points) * 2) + mesh.mode = "lines" + + bounds = self.get_px_bounds() + px_xmin = bounds["xmin"] + px_xmax = bounds["xmax"] + y_px = self.y_px() + for k, y in enumerate(points): + y = y_px(y) + vert[k * 8] = px_xmin + vert[k * 8 + 1] = y + vert[k * 8 + 4] = px_xmax + vert[k * 8 + 5] = y + mesh.vertices = vert + + +class VBar(MeshLinePlot): + '''VBar draw vertical bar on all the X points provided + ''' + + def plot_mesh(self, *args): + points = self.points + mesh, vert, ind = self.set_mesh_size(len(points) * 2) + mesh.mode = "lines" + + bounds = self.get_px_bounds() + px_ymin = bounds["ymin"] + px_ymax = bounds["ymax"] + x_px = self.x_px() + for k, x in enumerate(points): + x = x_px(x) + vert[k * 8] = x + vert[k * 8 + 1] = px_ymin + vert[k * 8 + 4] = x + vert[k * 8 + 5] = px_ymax + mesh.vertices = vert + + +class ScatterPlot(Plot): + """ + ScatterPlot draws using a standard Point object. + The pointsize can be controlled with :attr:`point_size`. + + >>> plot = ScatterPlot(color=[1, 0, 0, 1], point_size=5) + """ + + point_size = NumericProperty(1) + """The point size of the scatter points. Defaults to 1. + """ + + def create_drawings(self): + from kivy.graphics import Point, RenderContext + + self._points_context = RenderContext( + use_parent_modelview=True, + use_parent_projection=True) + with self._points_context: + self._gcolor = Color(*self.color) + self._gpts = Point(points=[], pointsize=self.point_size) + + return [self._points_context] + + def draw(self, *args): + super(ScatterPlot, self).draw(*args) + # flatten the list + self._gpts.points = list(chain(*self.iterate_points())) + + def on_point_size(self, *largs): + if hasattr(self, "_gpts"): + self._gpts.pointsize = self.point_size + + +class PointPlot(Plot): + '''Displays a set of points. + ''' + + point_size = NumericProperty(1) + ''' + Defaults to 1. + ''' + + _color = None + + _point = None + + def __init__(self, **kwargs): + super(PointPlot, self).__init__(**kwargs) + + def update_size(*largs): + if self._point: + self._point.pointsize = self.point_size + self.fbind('point_size', update_size) + + def update_color(*largs): + if self._color: + self._color.rgba = self.color + self.fbind('color', update_color) + + def create_drawings(self): + self._color = Color(*self.color) + self._point = Point(pointsize=self.point_size) + return [self._color, self._point] + + def draw(self, *args): + super(PointPlot, self).draw(*args) + self._point.points = [v for p in self.iterate_points() for v in p] + + +if __name__ == '__main__': + import itertools + from math import sin, cos, pi + from random import randrange + from kivy.utils import get_color_from_hex as rgb + from kivy.uix.boxlayout import BoxLayout + from kivy.app import App + + class TestApp(App): + + def build(self): + b = BoxLayout(orientation='vertical') + # example of a custom theme + colors = itertools.cycle([ + rgb('7dac9f'), rgb('dc7062'), rgb('66a8d4'), rgb('e5b060')]) + graph_theme = { + 'label_options': { + 'color': rgb('444444'), # color of tick labels and titles + 'bold': True}, + 'background_color': rgb('f8f8f2'), # canvas background color + 'tick_color': rgb('808080'), # ticks and grid + 'border_color': rgb('808080')} # border drawn around each graph + + graph = Graph( + xlabel='Cheese', + ylabel='Apples', + x_ticks_minor=5, + x_ticks_major=25, + y_ticks_major=1, + y_grid_label=True, + x_grid_label=True, + padding=5, + xlog=False, + ylog=False, + x_grid=True, + y_grid=True, + xmin=-50, + xmax=50, + ymin=-1, + ymax=1, + **graph_theme) + + plot = SmoothLinePlot(color=next(colors)) + plot.points = [(x / 10., sin(x / 50.)) for x in range(-500, 501)] + # for efficiency, the x range matches xmin, xmax + graph.add_plot(plot) + + plot = MeshLinePlot(color=next(colors)) + plot.points = [(x / 10., cos(x / 50.)) for x in range(-500, 501)] + graph.add_plot(plot) + self.plot = plot # this is the moving graph, so keep a reference + + plot = MeshStemPlot(color=next(colors)) + graph.add_plot(plot) + plot.points = [(x, x / 50.) for x in range(-50, 51)] + + plot = BarPlot(color=next(colors), bar_spacing=.72) + graph.add_plot(plot) + plot.bind_to_graph(graph) + plot.points = [(x, .1 + randrange(10) / 10.) for x in range(-50, 1)] + + Clock.schedule_interval(self.update_points, 1 / 60.) + + graph2 = Graph( + xlabel='Position (m)', + ylabel='Time (s)', + x_ticks_minor=0, + x_ticks_major=1, + y_ticks_major=10, + y_grid_label=True, + x_grid_label=True, + padding=5, + xlog=False, + ylog=False, + xmin=0, + ymin=0, + **graph_theme) + b.add_widget(graph) + + if np is not None: + (xbounds, ybounds, data) = self.make_contour_data() + # This is required to fit the graph to the data extents + graph2.xmin, graph2.xmax = xbounds + graph2.ymin, graph2.ymax = ybounds + + plot = ContourPlot() + plot.data = data + plot.xrange = xbounds + plot.yrange = ybounds + plot.color = [1, 0.7, 0.2, 1] + graph2.add_plot(plot) + + b.add_widget(graph2) + self.contourplot = plot + + Clock.schedule_interval(self.update_contour, 1 / 60.) + + # Test the scatter plot + plot = ScatterPlot(color=next(colors), point_size=5) + graph.add_plot(plot) + plot.points = [(x, .1 + randrange(10) / 10.) for x in range(-50, 1)] + return b + + def make_contour_data(self, ts=0): + omega = 2 * pi / 30 + k = (2 * pi) / 2.0 + + ts = sin(ts * 2) + 1.5 # emperically determined 'pretty' values + npoints = 100 + data = np.ones((npoints, npoints)) + + position = [ii * 0.1 for ii in range(npoints)] + time = [(ii % 100) * 0.6 for ii in range(npoints)] + + for ii, t in enumerate(time): + for jj, x in enumerate(position): + data[ii, jj] = sin( + k * x + omega * t) + sin(-k * x + omega * t) / ts + return (0, max(position)), (0, max(time)), data + + def update_points(self, *args): + self.plot.points = [ + (x / 10., cos(Clock.get_time() + x / 50.)) + for x in range(-500, 501)] + + def update_contour(self, *args): + _, _, self.contourplot.data[:] = self.make_contour_data( + Clock.get_time()) + # this does not trigger an update, because we replace the + # values of the arry and do not change the object. + # However, we cannot do "...data = make_contour_data()" as + # kivy will try to check for the identity of the new and + # old values. In numpy, 'nd1 == nd2' leads to an error + # (you have to use np.all). Ideally, property should be patched + # for this. + self.contourplot.ask_draw() + + TestApp().run() diff --git a/kivycharts/line.py b/kivycharts/line.py new file mode 100644 index 0000000..2007b2c --- /dev/null +++ b/kivycharts/line.py @@ -0,0 +1,130 @@ +from kivy.properties import DictProperty, ListProperty, \ + StringProperty, NumericProperty +from kivy.factory import Factory +from kivy.utils import get_color_from_hex as rgb + +from kivyblocks.scrollpanel import ScrollPanel +from kivyblocks.utils import SUPER, CSize +from kivyblocks.threadcall import HttpClient +from kivyblocks.baseWidget import VBox + +from .graph import Graph +from .graph import Plot, LinePlot, SmoothLinePlot + +build_plots = { + 'fold-line':LinePlot, + 'smooth-line':SmoothLinePlot +} + +class LineChart(VBox): + """ + series = [ + { + yfield:xxxx, + charttype:smooth-line, fold-line, ... + color: + } + ] + """ + dataurl = StringProperty(None) + params = DictProperty({}) + method = StringProperty('get') + xlabel = StringProperty(None) + ylabel = StringProperty(None) + xfield = StringProperty(None) + x_ticks_angle = NumericProperty(45) + series = ListProperty(None) + data = ListProperty(None) + def __init__(self, **kw): + self.graph = None + SUPER(LineChart, self, kw) + + def on_dataurl(self, o, url=None): + if not self.dataurl: + return + hc = HttpClient() + x = hc(self.dataurl, + method=self.method, + params=self.params) + self.data = x['rows'] + self.url_call = True + + def on_params(self, o, params=None): + if not self.url_call: + return + self.on_dataurl(None, None) + + def build_plot(self, serie): + type = serie.get('charttype', 'smooth-line') + plotKlass = build_plots.get(type) + p = plotKlass(color=serie['color']) + return p + + def on_data(self, o, data=None): + graph_theme = { + 'label_options': { + 'color': rgb('444444'), # color of tick labels and titles + 'bold': True}, + 'background_color': rgb('f8f8f2'), # canvas background color + 'tick_color': rgb('808080'), # ticks and grid + 'border_color': rgb('808080')} # border drawn around each graph + + xcnt = len(self.data) + ymin, ymax = 9999999999999999, 0 + xlabel_text = [ r.get(self.xfield) for r in self.data ] + xlabel_text.insert(0,'0') + for s in self.series: + points = [ (i, r.get(s['yfield'])) \ + for i,r in enumerate(self.data) ] + min1 = min([p[1] for p in points]) + max1 = max([p[1] for p in points]) + if max1 >ymax: + ymax = max1 + if min1 < ymin: + ymin = min1 + s['points'] = points + yadd = int((ymax - ymin) / 8) + ymin = int(ymin - yadd) + ymax = int(ymax + yadd) + y_ticks_major = int((ymax-ymin)/4) + x_ticks_major = int(xcnt/10) + xlabel = self.xlabel or self.xfield + ylabel = self.ylabel + + if not self.graph: + self.graph = Graph( + xlabel = self.xlabel or self.xfield, + ylabel = self.ylabel, + y_ticks_major = int((ymax-ymin)/4), + x_ticks_major = int(xcnt/10), + ymin = ymin, + ymax = ymax, + xmin = 0, + xmax = xcnt, + y_grid_label=True, + x_grid_label=True, + xlog = False, + ylog = False, + x_grid = True, + y_grid = True, + padding = 5, + **graph_theme) + self.add_widget(self.graph) + else: + self.graph.xlabel = xlabel + self.graph.ylabel = ylabel + self.graph.ymin = ymin + self.graph.ymax = ymax + self.graph.xmin = 0 + self.graph.xmax = xmax + self.graph.y_ticks_major = y_ticks_major + self.graph.x_ticks_major = x_ticks_major + + for s in self.series: + s['plot'] = self.build_plot(s) + s['plot'].points = s['points'] + self.graph.add_plot(s['plot']) + self.graph.x_grid_texts = xlabel_text + self.graph.x_ticks_angle = self.x_ticks_angle + +Factory.register('LineChart', LineChart) diff --git a/test/tgraph.py b/test/tgraph.py new file mode 100644 index 0000000..be6cb03 --- /dev/null +++ b/test/tgraph.py @@ -0,0 +1,147 @@ +from kivycharts.graph import * +from kivy.clock import Clock +import numpy as np + +if __name__ == '__main__': + import itertools + from math import sin, cos, pi + from random import randrange + from kivy.utils import get_color_from_hex as rgb + from kivy.uix.boxlayout import BoxLayout + from kivy.app import App + + class TestApp(App): + + def build(self): + b = BoxLayout(orientation='vertical') + # example of a custom theme + colors = itertools.cycle([ + rgb('0000FF'), rgb('dc7062'), rgb('66a8d4'), rgb('e5b060')]) + graph_theme = { + 'label_options': { + 'color': rgb('444444'), # color of tick labels and titles + 'bold': True}, + 'background_color': rgb('f8f8f2'), # canvas background color + 'tick_color': rgb('808080'), # ticks and grid + 'border_color': rgb('808080')} # border drawn around each graph + + graph = Graph( + xlabel='Cheese', + ylabel='Apples', + # x_ticks_minor=5, + # x_ticks_major=25, + x_ticks_major=5, + y_ticks_major=1, + y_grid_label=True, + x_grid_label=True, + padding=5, + xlog=False, + ylog=False, + x_grid=True, + y_grid=True, + xmin=-50, + xmax=50, + ymin=-1, + ymax=1, + **graph_theme) + + """ + c = next(colors) + print(c) + plot = SmoothLinePlot(color=c) + plot.points = [(x / 10., sin(x / 50.)) for x in range(-500, 501)] + # for efficiency, the x range matches xmin, xmax + graph.add_plot(plot) + """ + + plot = MeshLinePlot(color=next(colors)) + plot.points = [(x / 10., cos(x / 50.)) for x in range(-500, 501)] + graph.add_plot(plot) + self.plot = plot # this is the moving graph, so keep a reference + + """ + plot = MeshStemPlot(color=next(colors)) + graph.add_plot(plot) + plot.points = [(x, x / 50.) for x in range(-50, 51)] + """ + plot = BarPlot(color=next(colors), bar_spacing=.72) + graph.add_plot(plot) + plot.bind_to_graph(graph) + plot.points = [(x, .1 + randrange(10) / 10.) for x in range(-50, 1)] + # Test the scatter plot + plot = ScatterPlot(color=next(colors), point_size=5) + graph.add_plot(plot) + plot.points = [(x, .1 + randrange(10) / 10.) for x in range(-50, 1)] + Clock.schedule_interval(self.update_points, 1 / 60.) + + graph2 = Graph( + xlabel='Position (m)', + ylabel='Time (s)', + x_ticks_minor=0, + x_ticks_major=1, + y_ticks_major=10, + y_grid_label=True, + x_grid_label=True, + padding=5, + xlog=False, + ylog=False, + xmin=0, + ymin=0, + **graph_theme) + b.add_widget(graph) + + if np is not None: + (xbounds, ybounds, data) = self.make_contour_data() + # This is required to fit the graph to the data extents + graph2.xmin, graph2.xmax = xbounds + graph2.ymin, graph2.ymax = ybounds + + plot = ContourPlot() + plot.data = data + plot.xrange = xbounds + plot.yrange = ybounds + plot.color = [1, 0.7, 0.2, 1] + graph2.add_plot(plot) + + b.add_widget(graph2) + self.contourplot = plot + + Clock.schedule_interval(self.update_contour, 1 / 60.) + + return b + + def make_contour_data(self, ts=0): + omega = 2 * pi / 30 + k = (2 * pi) / 2.0 + + ts = sin(ts * 2) + 1.5 # emperically determined 'pretty' values + npoints = 100 + data = np.ones((npoints, npoints)) + + position = [ii * 0.1 for ii in range(npoints)] + time = [(ii % 100) * 0.6 for ii in range(npoints)] + + for ii, t in enumerate(time): + for jj, x in enumerate(position): + data[ii, jj] = sin( + k * x + omega * t) + sin(-k * x + omega * t) / ts + return (0, max(position)), (0, max(time)), data + + def update_points(self, *args): + self.plot.points = [ + (x / 10., cos(Clock.get_time() + x / 50.)) + for x in range(-500, 501)] + + def update_contour(self, *args): + _, _, self.contourplot.data[:] = self.make_contour_data( + Clock.get_time()) + # this does not trigger an update, because we replace the + # values of the arry and do not change the object. + # However, we cannot do "...data = make_contour_data()" as + # kivy will try to check for the identity of the new and + # old values. In numpy, 'nd1 == nd2' leads to an error + # (you have to use np.all). Ideally, property should be patched + # for this. + self.contourplot.ask_draw() + + TestApp().run()