From 5e0d1c60afd37b08ebb7d74a2e6cbd8248c61f03 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Sat, 16 Jan 2021 10:12:16 +0800 Subject: [PATCH] bugfix --- kivyblocks/baseWidget.py | 2 +- kivyblocks/bgcolorbehavior.py | 3 + kivyblocks/blocks.py | 53 +- kivyblocks/boxViewer.py | 2 +- kivyblocks/graph/.__init__.py.swp | Bin 81920 -> 0 bytes kivyblocks/graph/__init__.py | 2995 +++++++++--------- kivyblocks/mapview/__init__.py | 27 + kivyblocks/mapview/_version.py | 1 + kivyblocks/mapview/clustered_marker_layer.py | 449 +++ kivyblocks/mapview/constants.py | 5 + kivyblocks/mapview/downloader.py | 123 + kivyblocks/mapview/geojson.py | 381 +++ kivyblocks/mapview/icons/cluster.png | Bin 0 -> 600 bytes kivyblocks/mapview/icons/marker.png | Bin 0 -> 4203 bytes kivyblocks/mapview/mbtsource.py | 121 + kivyblocks/mapview/source.py | 212 ++ kivyblocks/mapview/types.py | 29 + kivyblocks/mapview/utils.py | 50 + kivyblocks/mapview/view.py | 999 ++++++ kivyblocks/register.py | 16 +- kivyblocks/responsivelayout.py | 34 +- kivyblocks/toggleitems.py | 13 +- kivyblocks/toolbar.py | 223 +- 23 files changed, 4067 insertions(+), 1671 deletions(-) delete mode 100644 kivyblocks/graph/.__init__.py.swp create mode 100644 kivyblocks/mapview/__init__.py create mode 100644 kivyblocks/mapview/_version.py create mode 100644 kivyblocks/mapview/clustered_marker_layer.py create mode 100644 kivyblocks/mapview/constants.py create mode 100644 kivyblocks/mapview/downloader.py create mode 100644 kivyblocks/mapview/geojson.py create mode 100644 kivyblocks/mapview/icons/cluster.png create mode 100644 kivyblocks/mapview/icons/marker.png create mode 100644 kivyblocks/mapview/mbtsource.py create mode 100644 kivyblocks/mapview/source.py create mode 100644 kivyblocks/mapview/types.py create mode 100644 kivyblocks/mapview/utils.py create mode 100644 kivyblocks/mapview/view.py diff --git a/kivyblocks/baseWidget.py b/kivyblocks/baseWidget.py index 2cc0fb0..c811c29 100755 --- a/kivyblocks/baseWidget.py +++ b/kivyblocks/baseWidget.py @@ -10,6 +10,7 @@ from kivy.uix.label import Label from kivy.event import EventDispatcher from kivy.metrics import dp from kivy.core.window import Window +from kivy.clock import Clock from kivy.uix.actionbar import ActionBar,ActionView,ActionPrevious,ActionItem,ActionButton from kivy.uix.actionbar import ActionToggleButton, ActionCheck,ActionSeparator,ActionGroup @@ -213,7 +214,6 @@ class Title6(Text): class Modal(BGColorBehavior, ModalView): def __init__(self, auto_open=False, color_level=-1, radius=[], **kw): - kw.update({'auto_dismiss':False}) ModalView.__init__(self, **kw) BGColorBehavior.__init__(self, color_level=color_level, radius=radius) diff --git a/kivyblocks/bgcolorbehavior.py b/kivyblocks/bgcolorbehavior.py index d50988b..b71bbd0 100644 --- a/kivyblocks/bgcolorbehavior.py +++ b/kivyblocks/bgcolorbehavior.py @@ -48,6 +48,9 @@ class BGColorBehavior(object): else: print('on_bgcolor():self.canvas is None') + def is_selected(self): + return self.bgcolor == self.selected_bgcolor + def selected(self): if self.bgcolor == self.selected_bgcolor: return diff --git a/kivyblocks/blocks.py b/kivyblocks/blocks.py index 998189f..61b6b6e 100755 --- a/kivyblocks/blocks.py +++ b/kivyblocks/blocks.py @@ -192,7 +192,10 @@ class Blocks(EventDispatcher): errback=None,**kw): if url is None: - errback(None,Exception('url is None')) + if errback: + errback(None,Exception('url is None')) + else: + return None if url.startswith('file://'): filename = url[7:] @@ -201,19 +204,14 @@ class Blocks(EventDispatcher): dic = json.loads(b) return dic elif url.startswith('http://') or url.startswith('https://'): - """ - h = HTTPDataHandler(url,method=method,params=params, - files=files) - h.bind(on_success=callback) - h.bind(on_error=errback) - h.handle() - """ try: hc = HttpClient() resp=hc(url,method=method,params=params,files=files) return resp except Exception as e: - errback(None,e) + if errback: + return errback(None,e) + return None else: config = getConfig() url = config.uihome + url @@ -312,7 +310,7 @@ class Blocks(EventDispatcher): if isinstance(v,dict) and v.get('widgettype'): b = Blocks() w = b.w_build(v) - if hasattr(widgets,k): + if hasattr(widget,k): aw = getattr(widget,k) if isinstance(aw,Layout): aw.add_widget(w) @@ -370,7 +368,7 @@ class Blocks(EventDispatcher): return f = self.buildAction(widget,desc) if f is None: - Logger.Info('Block: get a null function,%s',str(desc)) + Logger.info('Block: get a null function,%s',str(desc)) return w.bind(**{event:f}) @@ -392,8 +390,9 @@ class Blocks(EventDispatcher): def blocksAction(self,widget,desc, *args): target = Blocks.getWidgetById(desc.get('target','self'),widget) if target is None: - Logger.Info('Block: blocksAction():desc(%s) target not found', + Logger.info('Block: blocksAction():desc(%s) target not found', str(desc)) + return add_mode = desc.get('mode','replace') opts = desc.get('options').copy() d = self.getActionData(widget,desc) @@ -418,8 +417,9 @@ class Blocks(EventDispatcher): def urlwidgetAction(self,widget,desc, *args): target = Blocks.getWidgetById(desc.get('target','self'),widget) if target is None: - Logger.Info('Block: urlwidgetAction():desc(%s) target not found', + Logger.info('Block: urlwidgetAction():desc(%s) target not found', str(desc)) + return add_mode = desc.get('mode','replace') opts = desc.get('options').copy() p = opts.get('params',{}).copy() @@ -470,13 +470,14 @@ class Blocks(EventDispatcher): target = Blocks.getWidgetById(desc.get('target','self'), from_widget=widget) if target is None: - Logger.Info('Block: registedfunctionAction():desc(%s) target not found', + Logger.info('Block: registedfunctionAction():desc(%s) target not found', str(desc)) + return rf = RegisterFunction() name = desc.get('rfname') func = rf.get(name) if func is None: - Logger.Info('Block: desc=%s rfname(%s) not found', + Logger.info('Block: desc=%s rfname(%s) not found', str(desc), name) raise Exception('rfname(%s) not found' % name) @@ -488,13 +489,13 @@ class Blocks(EventDispatcher): def scriptAction(self, widget, desc, *args): script = desc.get('script') if not script: - Logger.Info('Block: scriptAction():desc(%s) target not found', + Logger.info('Block: scriptAction():desc(%s) target not found', str(desc)) return target = Blocks.getWidgetById(desc.get('target','self'), from_widget=widget) if target is None: - Logger.Info('Block: scriptAction():desc(%s) target not found', + Logger.info('Block: scriptAction():desc(%s) target not found', str(desc)) d = self.getActionData(widget,desc) ns = { @@ -512,8 +513,9 @@ class Blocks(EventDispatcher): method = desc.get('method') target = Blocks.getWidgetById(desc.get('target','self'),widget) if target is None: - Logger.Info('Block: methodAction():desc(%s) target not found', + Logger.info('Block: methodAction():desc(%s) target not found', str(desc)) + return if hasattr(target,method): f = getattr(target, method) kwargs = desc.get('options',{}).copy() @@ -536,8 +538,6 @@ class Blocks(EventDispatcher): ] } """ - name = desc['widgettype'] - def doit(desc): if not isinstance(desc,dict): Logger.info('Block: desc must be a dict object', @@ -550,6 +550,12 @@ class Blocks(EventDispatcher): if hasattr(widget,'ready'): widget.ready() + if not (isinstance(desc, DictObject) or isinstance(desc, dict)): + print('Block: desc must be a dict object', + desc,type(desc)) + self.dispatch('on_failed',Exception('miss url')) + return + while desc['widgettype'] == "urlwidget": opts = desc.get('options',{}).copy() extend = desc.get('extend') @@ -564,6 +570,13 @@ class Blocks(EventDispatcher): if opts.get('url'): del opts['url'] desc = self.getUrlData(url,**opts) + if not (isinstance(desc, DictObject) or \ + isinstance(desc, dict)): + print('Block: desc must be a dict object', + desc,type(desc)) + self.dispatch('on_failed',Exception('miss url')) + return + if addon: desc = dictExtend(desc,addon) doit(desc) diff --git a/kivyblocks/boxViewer.py b/kivyblocks/boxViewer.py index 36bd6e3..9760b6f 100644 --- a/kivyblocks/boxViewer.py +++ b/kivyblocks/boxViewer.py @@ -75,7 +75,7 @@ class BoxViewer(WidgetReady, BoxLayout): self.dataloader.bind(on_submit=self.getParams) self.add_widget(self.viewContainer) self.register_event_type('on_selected') - self.viewContainer.bind(size=self.resetCols, + self.bind(size=self.resetCols, pos=self.resetCols) self.viewContainer.bind(on_scroll_stop=self.on_scroll_stop) # use_keyboard() will block the python diff --git a/kivyblocks/graph/.__init__.py.swp b/kivyblocks/graph/.__init__.py.swp deleted file mode 100644 index dab12606fbf4079b369b753c6ab6698380ad4e9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 81920 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 diff --git a/kivyblocks/graph/__init__.py b/kivyblocks/graph/__init__.py index 57e9f30..c6b2e99 100644 --- a/kivyblocks/graph/__init__.py +++ b/kivyblocks/graph/__init__.py @@ -16,14 +16,14 @@ 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) + 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 @@ -38,20 +38,20 @@ 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 + * `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. + 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. ''' __all__ = ('Graph', 'Plot', 'MeshLinePlot', 'MeshStemPlot', 'LinePlot', - 'SmoothLinePlot', 'ContourPlot', 'ScatterPlot', 'PointPlot', + 'SmoothLinePlot', 'ContourPlot', 'ScatterPlot', 'PointPlot', 'BarPlot', 'HBar', 'VBar', ) @@ -59,8 +59,8 @@ 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 + BoundedNumericProperty, StringProperty, ListProperty, ObjectProperty,\ + DictProperty, AliasProperty from kivy.clock import Clock from kivy.graphics import Mesh, Color, Rectangle, Point from kivy.graphics import Fbo @@ -73,1681 +73,1682 @@ from math import log10, floor, ceil from decimal import Decimal from itertools import chain try: - import numpy as np + import numpy as np except ImportError as e: - np = None + np = None from ._version import __version__ def identity(x): - return x + return x def exp10(x): - return 10 ** x + return 10 ** x Builder.load_string(""" : - canvas.before: - PushMatrix - Rotate: - angle: root.angle - axis: 0, 0, 1 - origin: root.center - canvas.after: - PopMatrix + canvas.before: + PushMatrix + Rotate: + angle: root.angle + axis: 0, 0, 1 + origin: root.center + canvas.after: + PopMatrix """) class GraphRotatedLabel(Label): - angle = NumericProperty(0) + angle = NumericProperty(0) class Axis(EventDispatcher): - pass + pass class XAxis(Axis): - pass + pass class YAxis(Axis): - pass + 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 - ''' - - 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: - 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 - 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: - 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)): - 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. + '''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 + ''' + + 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: + 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 + 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: + 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)): + 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. + If :data:`ylog` is True, ymin must be larger than zero. - :data:`ymin` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. - ''' + :data:`ymin` is a :class:`~kivy.properties.NumericProperty`, defaults to 0. + ''' - ymax = NumericProperty(100.) - '''The y-axis maximum value, larger than ymin. + 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. - ''' + :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. + 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. - ''' + :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. + 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 ''. - ''' + :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. + 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. - ''' + :data:`padding` is a :class:`~kivy.properties.NumericProperty`, defaults + to 5dp. + ''' - font_size = NumericProperty('15sp') - '''Font size of the labels. + font_size = NumericProperty('15sp') + '''Font size of the labels. - :data:`font_size` is a :class:`~kivy.properties.NumericProperty`, defaults - to 15sp. - ''' + :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. + 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. - ''' + :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. + 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'. - ''' + :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. + 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. - ''' + :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. + 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 []. - ''' + :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. - ''' + 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. + ''' class Plot(EventDispatcher): - '''Plot class, see module documentation for more information. + '''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. + :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 - ''' + ..versionadded:: 0.4 + ''' - __events__ = ('on_clear_plot', ) + __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)}) + # 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. - ''' + color = ListProperty([1, 1, 1, 1]) + '''Color of the plot. + ''' - points = ListProperty([]) - '''List of (x, y) points to be displayed in 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. + 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 - []. - ''' + :data:`points` is a :class:`~kivy.properties.ListProperty`, defaults to + []. + ''' - x_axis = NumericProperty(0) - '''Index of the X axis to use, defaults to 0 - ''' + 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 - ''' + 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 __init__(self, **kwargs): + print(kwargs,',class=') + 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 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 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 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 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 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 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 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_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 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 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 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 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 + def on_clear_plot(self, *largs): + pass - # compatibility layer - _update = update - _get_drawings = get_drawings - _params = params + # 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. - ''' + '''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 + 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. + 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'. - ''' + 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 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 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 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 + 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. - ''' + '''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 + 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. - """ + """LinePlot draws using a standard Line object. + """ - line_width = NumericProperty(1) + line_width = NumericProperty(1) - def create_drawings(self): - from kivy.graphics import Line, RenderContext + 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') + 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] + 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 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 + 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 Plot class, see module documentation for more information. + This plot use a specific Fragment shader for a custom anti aliasing. + ''' - SMOOTH_FS = ''' - $HEADER$ + 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); - } - ''' + 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") + # 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 + 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) + # 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) + 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] + return [self._grc] - @staticmethod - def _smooth_reload_observer(texture): - texture.blit_buffer(SmoothLinePlot.GRADIENT_DATA, colorfmt="rgb") + @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 + 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]) + """ + 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 __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 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 + 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)) + # 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 + 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) + 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. - ''' + '''BarPlot class which displays a bar graph. + ''' - bar_width = NumericProperty(1) - bar_spacing = NumericProperty(1.) - graph = ObjectProperty(allownone=True) + 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 __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 + 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]))) + 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) + 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 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 + 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] + # 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)) + 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) + 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"] + 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]) + 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 + 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 _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 + def bind_to_graph(self, graph): + old_graph = self.graph - if old_graph: - # unbind from the old one - self._unbind_graph(old_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) + # 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) + 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 - ''' + '''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" + 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 + 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 - ''' + '''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" + 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 + 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`. + """ + 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) - """ + >>> plot = ScatterPlot(color=[1, 0, 0, 1], point_size=5) + """ - point_size = NumericProperty(1) - """The point size of the scatter points. Defaults to 1. - """ + point_size = NumericProperty(1) + """The point size of the scatter points. Defaults to 1. + """ - def create_drawings(self): - from kivy.graphics import Point, RenderContext + 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) + 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] + 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 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 + def on_point_size(self, *largs): + if hasattr(self, "_gpts"): + self._gpts.pointsize = self.point_size class PointPlot(Plot): - '''Displays a set of points. - ''' + '''Displays a set of points. + ''' - point_size = NumericProperty(1) - ''' - Defaults to 1. - ''' + point_size = NumericProperty(1) + ''' + Defaults to 1. + ''' - _color = None + _color = None - _point = None + _point = None - def __init__(self, **kwargs): - super(PointPlot, self).__init__(**kwargs) + 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_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 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 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] + 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 + 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): + 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 + 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) + 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 = 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 = 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 = 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)] + 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.) + 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) + 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 + 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) + 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 + b.add_widget(graph2) + self.contourplot = plot - Clock.schedule_interval(self.update_contour, 1 / 60.) + Clock.schedule_interval(self.update_contour, 1 / 60.) - # Test the scatter plot - plot = ScatterPlot(color=next(colors), pointsize=5) - graph.add_plot(plot) - plot.points = [(x, .1 + randrange(10) / 10.) for x in range(-50, 1)] - return b + # 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 + 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)) + 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)] + 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 + 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_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() + 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() + TestApp().run() diff --git a/kivyblocks/mapview/__init__.py b/kivyblocks/mapview/__init__.py new file mode 100644 index 0000000..3ca52a7 --- /dev/null +++ b/kivyblocks/mapview/__init__.py @@ -0,0 +1,27 @@ +# coding=utf-8 +""" +MapView +======= + +MapView is a Kivy widget that display maps. +""" +from kivyblocks.mapview.source import MapSource +from kivyblocks.mapview.types import Bbox, Coordinate +from kivyblocks.mapview.view import ( + MapLayer, + MapMarker, + MapMarkerPopup, + MapView, + MarkerMapLayer, +) + +__all__ = [ + "Coordinate", + "Bbox", + "MapView", + "MapSource", + "MapMarker", + "MapLayer", + "MarkerMapLayer", + "MapMarkerPopup", +] diff --git a/kivyblocks/mapview/_version.py b/kivyblocks/mapview/_version.py new file mode 100644 index 0000000..68cdeee --- /dev/null +++ b/kivyblocks/mapview/_version.py @@ -0,0 +1 @@ +__version__ = "1.0.5" diff --git a/kivyblocks/mapview/clustered_marker_layer.py b/kivyblocks/mapview/clustered_marker_layer.py new file mode 100644 index 0000000..e5377ed --- /dev/null +++ b/kivyblocks/mapview/clustered_marker_layer.py @@ -0,0 +1,449 @@ +# coding=utf-8 +""" +Layer that support point clustering +=================================== +""" + +from math import atan, exp, floor, log, pi, sin, sqrt +from os.path import dirname, join + +from kivy.lang import Builder +from kivy.metrics import dp +from kivy.properties import ( + ListProperty, + NumericProperty, + ObjectProperty, + StringProperty, +) + +from kivyblocks.mapview.view import MapLayer, MapMarker + +Builder.load_string( + """ +: + size_hint: None, None + source: root.source + size: list(map(dp, self.texture_size)) + allow_stretch: True + + Label: + color: root.text_color + pos: root.pos + size: root.size + text: "{}".format(root.num_points) + font_size: dp(18) +""" +) + + +# longitude/latitude to spherical mercator in [0..1] range +def lngX(lng): + return lng / 360.0 + 0.5 + + +def latY(lat): + if lat == 90: + return 0 + if lat == -90: + return 1 + s = sin(lat * pi / 180.0) + y = 0.5 - 0.25 * log((1 + s) / (1 - s)) / pi + return min(1, max(0, y)) + + +# spherical mercator to longitude/latitude +def xLng(x): + return (x - 0.5) * 360 + + +def yLat(y): + y2 = (180 - y * 360) * pi / 180 + return 360 * atan(exp(y2)) / pi - 90 + + +class KDBush: + """ + kdbush implementation from: + https://github.com/mourner/kdbush/blob/master/src/kdbush.js + """ + + def __init__(self, points, node_size=64): + self.points = points + self.node_size = node_size + + self.ids = ids = [0] * len(points) + self.coords = coords = [0] * len(points) * 2 + for i, point in enumerate(points): + ids[i] = i + coords[2 * i] = point.x + coords[2 * i + 1] = point.y + + self._sort(ids, coords, node_size, 0, len(ids) - 1, 0) + + def range(self, min_x, min_y, max_x, max_y): + return self._range( + self.ids, self.coords, min_x, min_y, max_x, max_y, self.node_size + ) + + def within(self, x, y, r): + return self._within(self.ids, self.coords, x, y, r, self.node_size) + + def _sort(self, ids, coords, node_size, left, right, depth): + if right - left <= node_size: + return + m = int(floor((left + right) / 2.0)) + self._select(ids, coords, m, left, right, depth % 2) + self._sort(ids, coords, node_size, left, m - 1, depth + 1) + self._sort(ids, coords, node_size, m + 1, right, depth + 1) + + def _select(self, ids, coords, k, left, right, inc): + swap_item = self._swap_item + while right > left: + if (right - left) > 600: + n = float(right - left + 1) + m = k - left + 1 + z = log(n) + s = 0.5 + exp(2 * z / 3.0) + sd = 0.5 * sqrt(z * s * (n - s) / n) * (-1 if (m - n / 2.0) < 0 else 1) + new_left = max(left, int(floor(k - m * s / n + sd))) + new_right = min(right, int(floor(k + (n - m) * s / n + sd))) + self._select(ids, coords, k, new_left, new_right, inc) + + t = coords[2 * k + inc] + i = left + j = right + + swap_item(ids, coords, left, k) + if coords[2 * right + inc] > t: + swap_item(ids, coords, left, right) + + while i < j: + swap_item(ids, coords, i, j) + i += 1 + j -= 1 + while coords[2 * i + inc] < t: + i += 1 + while coords[2 * j + inc] > t: + j -= 1 + + if coords[2 * left + inc] == t: + swap_item(ids, coords, left, j) + else: + j += 1 + swap_item(ids, coords, j, right) + + if j <= k: + left = j + 1 + if k <= j: + right = j - 1 + + def _swap_item(self, ids, coords, i, j): + swap = self._swap + swap(ids, i, j) + swap(coords, 2 * i, 2 * j) + swap(coords, 2 * i + 1, 2 * j + 1) + + def _swap(self, arr, i, j): + tmp = arr[i] + arr[i] = arr[j] + arr[j] = tmp + + def _range(self, ids, coords, min_x, min_y, max_x, max_y, node_size): + stack = [0, len(ids) - 1, 0] + result = [] + x = y = 0 + + while stack: + axis = stack.pop() + right = stack.pop() + left = stack.pop() + + if right - left <= node_size: + for i in range(left, right + 1): + x = coords[2 * i] + y = coords[2 * i + 1] + if x >= min_x and x <= max_x and y >= min_y and y <= max_y: + result.append(ids[i]) + continue + + m = int(floor((left + right) / 2.0)) + + x = coords[2 * m] + y = coords[2 * m + 1] + + if x >= min_x and x <= max_x and y >= min_y and y <= max_y: + result.append(ids[m]) + + nextAxis = (axis + 1) % 2 + + if min_x <= x if axis == 0 else min_y <= y: + stack.append(left) + stack.append(m - 1) + stack.append(nextAxis) + if max_x >= x if axis == 0 else max_y >= y: + stack.append(m + 1) + stack.append(right) + stack.append(nextAxis) + + return result + + def _within(self, ids, coords, qx, qy, r, node_size): + sq_dist = self._sq_dist + stack = [0, len(ids) - 1, 0] + result = [] + r2 = r * r + + while stack: + axis = stack.pop() + right = stack.pop() + left = stack.pop() + + if right - left <= node_size: + for i in range(left, right + 1): + if sq_dist(coords[2 * i], coords[2 * i + 1], qx, qy) <= r2: + result.append(ids[i]) + continue + + m = int(floor((left + right) / 2.0)) + + x = coords[2 * m] + y = coords[2 * m + 1] + + if sq_dist(x, y, qx, qy) <= r2: + result.append(ids[m]) + + nextAxis = (axis + 1) % 2 + + if (qx - r <= x) if axis == 0 else (qy - r <= y): + stack.append(left) + stack.append(m - 1) + stack.append(nextAxis) + if (qx + r >= x) if axis == 0 else (qy + r >= y): + stack.append(m + 1) + stack.append(right) + stack.append(nextAxis) + + return result + + def _sq_dist(self, ax, ay, bx, by): + dx = ax - bx + dy = ay - by + return dx * dx + dy * dy + + +class Cluster: + def __init__(self, x, y, num_points, id, props): + self.x = x + self.y = y + self.num_points = num_points + self.zoom = float("inf") + self.id = id + self.props = props + self.parent_id = None + self.widget = None + + # preprocess lon/lat + self.lon = xLng(x) + self.lat = yLat(y) + + +class Marker: + def __init__(self, lon, lat, cls=MapMarker, options=None): + self.lon = lon + self.lat = lat + self.cls = cls + self.options = options + + # preprocess x/y from lon/lat + self.x = lngX(lon) + self.y = latY(lat) + + # cluster information + self.id = None + self.zoom = float("inf") + self.parent_id = None + self.widget = None + + def __repr__(self): + return "".format( + self.lon, self.lat, self.source + ) + + +class SuperCluster: + """Port of supercluster from mapbox in pure python + """ + + def __init__(self, min_zoom=0, max_zoom=16, radius=40, extent=512, node_size=64): + self.min_zoom = min_zoom + self.max_zoom = max_zoom + self.radius = radius + self.extent = extent + self.node_size = node_size + + def load(self, points): + """Load an array of markers. + Once loaded, the index is immutable. + """ + from time import time + + self.trees = {} + self.points = points + + for index, point in enumerate(points): + point.id = index + + clusters = points + for z in range(self.max_zoom, self.min_zoom - 1, -1): + start = time() + print("build tree", z) + self.trees[z + 1] = KDBush(clusters, self.node_size) + print("kdbush", (time() - start) * 1000) + start = time() + clusters = self._cluster(clusters, z) + print(len(clusters)) + print("clustering", (time() - start) * 1000) + self.trees[self.min_zoom] = KDBush(clusters, self.node_size) + + def get_clusters(self, bbox, zoom): + """For the given bbox [westLng, southLat, eastLng, northLat], and + integer zoom, returns an array of clusters and markers + """ + tree = self.trees[self._limit_zoom(zoom)] + ids = tree.range(lngX(bbox[0]), latY(bbox[3]), lngX(bbox[2]), latY(bbox[1])) + clusters = [] + for i in range(len(ids)): + c = tree.points[ids[i]] + if isinstance(c, Cluster): + clusters.append(c) + else: + clusters.append(self.points[c.id]) + return clusters + + def _limit_zoom(self, z): + return max(self.min_zoom, min(self.max_zoom + 1, z)) + + def _cluster(self, points, zoom): + clusters = [] + c_append = clusters.append + trees = self.trees + r = self.radius / float(self.extent * pow(2, zoom)) + + # loop through each point + for i in range(len(points)): + p = points[i] + # if we've already visited the point at this zoom level, skip it + if p.zoom <= zoom: + continue + p.zoom = zoom + + # find all nearby points + tree = trees[zoom + 1] + neighbor_ids = tree.within(p.x, p.y, r) + + num_points = 1 + if isinstance(p, Cluster): + num_points = p.num_points + wx = p.x * num_points + wy = p.y * num_points + + props = None + + for j in range(len(neighbor_ids)): + b = tree.points[neighbor_ids[j]] + # filter out neighbors that are too far or already processed + if zoom < b.zoom: + num_points2 = 1 + if isinstance(b, Cluster): + num_points2 = b.num_points + # save the zoom (so it doesn't get processed twice) + b.zoom = zoom + # accumulate coordinates for calculating weighted center + wx += b.x * num_points2 + wy += b.y * num_points2 + num_points += num_points2 + b.parent_id = i + + if num_points == 1: + c_append(p) + else: + p.parent_id = i + c_append( + Cluster(wx / num_points, wy / num_points, num_points, i, props) + ) + return clusters + + +class ClusterMapMarker(MapMarker): + source = StringProperty(join(dirname(__file__), "icons", "cluster.png")) + cluster = ObjectProperty() + num_points = NumericProperty() + text_color = ListProperty([0.1, 0.1, 0.1, 1]) + + def on_cluster(self, instance, cluster): + self.num_points = cluster.num_points + + def on_touch_down(self, touch): + return False + + +class ClusteredMarkerLayer(MapLayer): + cluster_cls = ObjectProperty(ClusterMapMarker) + cluster_min_zoom = NumericProperty(0) + cluster_max_zoom = NumericProperty(16) + cluster_radius = NumericProperty("40dp") + cluster_extent = NumericProperty(512) + cluster_node_size = NumericProperty(64) + + def __init__(self, **kwargs): + self.cluster = None + self.cluster_markers = [] + super().__init__(**kwargs) + + def add_marker(self, lon, lat, cls=MapMarker, options=None): + if options is None: + options = {} + marker = Marker(lon, lat, cls, options) + self.cluster_markers.append(marker) + return marker + + def remove_marker(self, marker): + self.cluster_markers.remove(marker) + + def reposition(self): + if self.cluster is None: + self.build_cluster() + margin = dp(48) + mapview = self.parent + set_marker_position = self.set_marker_position + bbox = mapview.get_bbox(margin) + bbox = (bbox[1], bbox[0], bbox[3], bbox[2]) + self.clear_widgets() + for point in self.cluster.get_clusters(bbox, mapview.zoom): + widget = point.widget + if widget is None: + widget = self.create_widget_for(point) + set_marker_position(mapview, widget) + self.add_widget(widget) + + def build_cluster(self): + self.cluster = SuperCluster( + min_zoom=self.cluster_min_zoom, + max_zoom=self.cluster_max_zoom, + radius=self.cluster_radius, + extent=self.cluster_extent, + node_size=self.cluster_node_size, + ) + self.cluster.load(self.cluster_markers) + + def create_widget_for(self, point): + if isinstance(point, Marker): + point.widget = point.cls(lon=point.lon, lat=point.lat, **point.options) + elif isinstance(point, Cluster): + point.widget = self.cluster_cls(lon=point.lon, lat=point.lat, cluster=point) + return point.widget + + def set_marker_position(self, mapview, marker): + x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom) + marker.x = int(x - marker.width * marker.anchor_x) + marker.y = int(y - marker.height * marker.anchor_y) diff --git a/kivyblocks/mapview/constants.py b/kivyblocks/mapview/constants.py new file mode 100644 index 0000000..b6998f8 --- /dev/null +++ b/kivyblocks/mapview/constants.py @@ -0,0 +1,5 @@ +MIN_LATITUDE = -90.0 +MAX_LATITUDE = 90.0 +MIN_LONGITUDE = -180.0 +MAX_LONGITUDE = 180.0 +CACHE_DIR = "cache" diff --git a/kivyblocks/mapview/downloader.py b/kivyblocks/mapview/downloader.py new file mode 100644 index 0000000..3e1810d --- /dev/null +++ b/kivyblocks/mapview/downloader.py @@ -0,0 +1,123 @@ +# coding=utf-8 + +__all__ = ["Downloader"] + +import logging +import traceback +from concurrent.futures import ThreadPoolExecutor, TimeoutError, as_completed +from os import environ, makedirs +from os.path import exists, join +from random import choice +from time import time + +import requests +from kivy.clock import Clock +from kivy.logger import LOG_LEVELS, Logger + +from kivyblocks.mapview.constants import CACHE_DIR + +if "MAPVIEW_DEBUG_DOWNLOADER" in environ: + Logger.setLevel(LOG_LEVELS['debug']) + +# user agent is needed because since may 2019 OSM gives me a 429 or 403 server error +# I tried it with a simpler one (just Mozilla/5.0) this also gets rejected +USER_AGENT = 'Kivy-garden.mapview' + + +class Downloader: + _instance = None + MAX_WORKERS = 5 + CAP_TIME = 0.064 # 15 FPS + + @staticmethod + def instance(cache_dir=None): + if Downloader._instance is None: + if not cache_dir: + cache_dir = CACHE_DIR + Downloader._instance = Downloader(cache_dir=cache_dir) + return Downloader._instance + + def __init__(self, max_workers=None, cap_time=None, **kwargs): + self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) + if max_workers is None: + max_workers = Downloader.MAX_WORKERS + if cap_time is None: + cap_time = Downloader.CAP_TIME + self.is_paused = False + self.cap_time = cap_time + self.executor = ThreadPoolExecutor(max_workers=max_workers) + self._futures = [] + Clock.schedule_interval(self._check_executor, 1 / 60.0) + if not exists(self.cache_dir): + makedirs(self.cache_dir) + + def submit(self, f, *args, **kwargs): + future = self.executor.submit(f, *args, **kwargs) + self._futures.append(future) + + def download_tile(self, tile): + Logger.debug( + "Downloader: queue(tile) zoom={} x={} y={}".format( + tile.zoom, tile.tile_x, tile.tile_y + ) + ) + future = self.executor.submit(self._load_tile, tile) + self._futures.append(future) + + def download(self, url, callback, **kwargs): + Logger.debug("Downloader: queue(url) {}".format(url)) + future = self.executor.submit(self._download_url, url, callback, kwargs) + self._futures.append(future) + + def _download_url(self, url, callback, kwargs): + Logger.debug("Downloader: download(url) {}".format(url)) + response = requests.get(url, **kwargs) + response.raise_for_status() + return callback, (url, response) + + def _load_tile(self, tile): + if tile.state == "done": + return + cache_fn = tile.cache_fn + if exists(cache_fn): + Logger.debug("Downloader: use cache {}".format(cache_fn)) + return tile.set_source, (cache_fn,) + tile_y = tile.map_source.get_row_count(tile.zoom) - tile.tile_y - 1 + uri = tile.map_source.url.format( + z=tile.zoom, x=tile.tile_x, y=tile_y, s=choice(tile.map_source.subdomains) + ) + Logger.debug("Downloader: download(tile) {}".format(uri)) + response = requests.get(uri, headers={'User-agent': USER_AGENT}, timeout=5) + try: + response.raise_for_status() + data = response.content + with open(cache_fn, "wb") as fd: + fd.write(data) + Logger.debug("Downloaded {} bytes: {}".format(len(data), uri)) + return tile.set_source, (cache_fn,) + except Exception as e: + print("Downloader error: {!r}".format(e)) + + def _check_executor(self, dt): + start = time() + try: + for future in as_completed(self._futures[:], 0): + self._futures.remove(future) + try: + result = future.result() + except Exception: + traceback.print_exc() + # make an error tile? + continue + if result is None: + continue + callback, args = result + callback(*args) + + # capped executor in time, in order to prevent too much + # slowiness. + # seems to works quite great with big zoom-in/out + if time() - start > self.cap_time: + break + except TimeoutError: + pass diff --git a/kivyblocks/mapview/geojson.py b/kivyblocks/mapview/geojson.py new file mode 100644 index 0000000..0db18f8 --- /dev/null +++ b/kivyblocks/mapview/geojson.py @@ -0,0 +1,381 @@ +# coding=utf-8 +""" +Geojson layer +============= + +.. note:: + + Currently experimental and a work in progress, not fully optimized. + + +Supports: + +- html color in properties +- polygon geometry are cached and not redrawed when the parent mapview changes +- linestring are redrawed everymove, it's ugly and slow. +- marker are NOT supported + +""" + +__all__ = ["GeoJsonMapLayer"] + +import json + +from kivy.graphics import ( + Canvas, + Color, + Line, + MatrixInstruction, + Mesh, + PopMatrix, + PushMatrix, + Scale, + Translate, +) +from kivy.graphics.tesselator import TYPE_POLYGONS, WINDING_ODD, Tesselator +from kivy.metrics import dp +from kivy.properties import ObjectProperty, StringProperty +from kivy.utils import get_color_from_hex + +from kivyblocks.mapview.constants import CACHE_DIR +from kivyblocks.mapview.downloader import Downloader +from kivyblocks.mapview.view import MapLayer + +COLORS = { + 'aliceblue': '#f0f8ff', + 'antiquewhite': '#faebd7', + 'aqua': '#00ffff', + 'aquamarine': '#7fffd4', + 'azure': '#f0ffff', + 'beige': '#f5f5dc', + 'bisque': '#ffe4c4', + 'black': '#000000', + 'blanchedalmond': '#ffebcd', + 'blue': '#0000ff', + 'blueviolet': '#8a2be2', + 'brown': '#a52a2a', + 'burlywood': '#deb887', + 'cadetblue': '#5f9ea0', + 'chartreuse': '#7fff00', + 'chocolate': '#d2691e', + 'coral': '#ff7f50', + 'cornflowerblue': '#6495ed', + 'cornsilk': '#fff8dc', + 'crimson': '#dc143c', + 'cyan': '#00ffff', + 'darkblue': '#00008b', + 'darkcyan': '#008b8b', + 'darkgoldenrod': '#b8860b', + 'darkgray': '#a9a9a9', + 'darkgrey': '#a9a9a9', + 'darkgreen': '#006400', + 'darkkhaki': '#bdb76b', + 'darkmagenta': '#8b008b', + 'darkolivegreen': '#556b2f', + 'darkorange': '#ff8c00', + 'darkorchid': '#9932cc', + 'darkred': '#8b0000', + 'darksalmon': '#e9967a', + 'darkseagreen': '#8fbc8f', + 'darkslateblue': '#483d8b', + 'darkslategray': '#2f4f4f', + 'darkslategrey': '#2f4f4f', + 'darkturquoise': '#00ced1', + 'darkviolet': '#9400d3', + 'deeppink': '#ff1493', + 'deepskyblue': '#00bfff', + 'dimgray': '#696969', + 'dimgrey': '#696969', + 'dodgerblue': '#1e90ff', + 'firebrick': '#b22222', + 'floralwhite': '#fffaf0', + 'forestgreen': '#228b22', + 'fuchsia': '#ff00ff', + 'gainsboro': '#dcdcdc', + 'ghostwhite': '#f8f8ff', + 'gold': '#ffd700', + 'goldenrod': '#daa520', + 'gray': '#808080', + 'grey': '#808080', + 'green': '#008000', + 'greenyellow': '#adff2f', + 'honeydew': '#f0fff0', + 'hotpink': '#ff69b4', + 'indianred': '#cd5c5c', + 'indigo': '#4b0082', + 'ivory': '#fffff0', + 'khaki': '#f0e68c', + 'lavender': '#e6e6fa', + 'lavenderblush': '#fff0f5', + 'lawngreen': '#7cfc00', + 'lemonchiffon': '#fffacd', + 'lightblue': '#add8e6', + 'lightcoral': '#f08080', + 'lightcyan': '#e0ffff', + 'lightgoldenrodyellow': '#fafad2', + 'lightgray': '#d3d3d3', + 'lightgrey': '#d3d3d3', + 'lightgreen': '#90ee90', + 'lightpink': '#ffb6c1', + 'lightsalmon': '#ffa07a', + 'lightseagreen': '#20b2aa', + 'lightskyblue': '#87cefa', + 'lightslategray': '#778899', + 'lightslategrey': '#778899', + 'lightsteelblue': '#b0c4de', + 'lightyellow': '#ffffe0', + 'lime': '#00ff00', + 'limegreen': '#32cd32', + 'linen': '#faf0e6', + 'magenta': '#ff00ff', + 'maroon': '#800000', + 'mediumaquamarine': '#66cdaa', + 'mediumblue': '#0000cd', + 'mediumorchid': '#ba55d3', + 'mediumpurple': '#9370d8', + 'mediumseagreen': '#3cb371', + 'mediumslateblue': '#7b68ee', + 'mediumspringgreen': '#00fa9a', + 'mediumturquoise': '#48d1cc', + 'mediumvioletred': '#c71585', + 'midnightblue': '#191970', + 'mintcream': '#f5fffa', + 'mistyrose': '#ffe4e1', + 'moccasin': '#ffe4b5', + 'navajowhite': '#ffdead', + 'navy': '#000080', + 'oldlace': '#fdf5e6', + 'olive': '#808000', + 'olivedrab': '#6b8e23', + 'orange': '#ffa500', + 'orangered': '#ff4500', + 'orchid': '#da70d6', + 'palegoldenrod': '#eee8aa', + 'palegreen': '#98fb98', + 'paleturquoise': '#afeeee', + 'palevioletred': '#d87093', + 'papayawhip': '#ffefd5', + 'peachpuff': '#ffdab9', + 'peru': '#cd853f', + 'pink': '#ffc0cb', + 'plum': '#dda0dd', + 'powderblue': '#b0e0e6', + 'purple': '#800080', + 'red': '#ff0000', + 'rosybrown': '#bc8f8f', + 'royalblue': '#4169e1', + 'saddlebrown': '#8b4513', + 'salmon': '#fa8072', + 'sandybrown': '#f4a460', + 'seagreen': '#2e8b57', + 'seashell': '#fff5ee', + 'sienna': '#a0522d', + 'silver': '#c0c0c0', + 'skyblue': '#87ceeb', + 'slateblue': '#6a5acd', + 'slategray': '#708090', + 'slategrey': '#708090', + 'snow': '#fffafa', + 'springgreen': '#00ff7f', + 'steelblue': '#4682b4', + 'tan': '#d2b48c', + 'teal': '#008080', + 'thistle': '#d8bfd8', + 'tomato': '#ff6347', + 'turquoise': '#40e0d0', + 'violet': '#ee82ee', + 'wheat': '#f5deb3', + 'white': '#ffffff', + 'whitesmoke': '#f5f5f5', + 'yellow': '#ffff00', + 'yellowgreen': '#9acd32', +} + + +def flatten(lst): + return [item for sublist in lst for item in sublist] + + +class GeoJsonMapLayer(MapLayer): + + source = StringProperty() + geojson = ObjectProperty() + cache_dir = StringProperty(CACHE_DIR) + + def __init__(self, **kwargs): + self.first_time = True + self.initial_zoom = None + super().__init__(**kwargs) + with self.canvas: + self.canvas_polygon = Canvas() + self.canvas_line = Canvas() + with self.canvas_polygon.before: + PushMatrix() + self.g_matrix = MatrixInstruction() + self.g_scale = Scale() + self.g_translate = Translate() + with self.canvas_polygon: + self.g_canvas_polygon = Canvas() + with self.canvas_polygon.after: + PopMatrix() + + def reposition(self): + vx, vy = self.parent.delta_x, self.parent.delta_y + pzoom = self.parent.zoom + zoom = self.initial_zoom + if zoom is None: + self.initial_zoom = zoom = pzoom + if zoom != pzoom: + diff = 2 ** (pzoom - zoom) + vx /= diff + vy /= diff + self.g_scale.x = self.g_scale.y = diff + else: + self.g_scale.x = self.g_scale.y = 1.0 + self.g_translate.xy = vx, vy + self.g_matrix.matrix = self.parent._scatter.transform + + if self.geojson: + update = not self.first_time + self.on_geojson(self, self.geojson, update=update) + self.first_time = False + + def traverse_feature(self, func, part=None): + """Traverse the whole geojson and call the func with every element + found. + """ + if part is None: + part = self.geojson + if not part: + return + tp = part["type"] + if tp == "FeatureCollection": + for feature in part["features"]: + func(feature) + elif tp == "Feature": + func(part) + + @property + def bounds(self): + # return the min lon, max lon, min lat, max lat + bounds = [float("inf"), float("-inf"), float("inf"), float("-inf")] + + def _submit_coordinate(coord): + lon, lat = coord + bounds[0] = min(bounds[0], lon) + bounds[1] = max(bounds[1], lon) + bounds[2] = min(bounds[2], lat) + bounds[3] = max(bounds[3], lat) + + def _get_bounds(feature): + geometry = feature["geometry"] + tp = geometry["type"] + if tp == "Point": + _submit_coordinate(geometry["coordinates"]) + elif tp == "Polygon": + for coordinate in geometry["coordinates"][0]: + _submit_coordinate(coordinate) + elif tp == "MultiPolygon": + for polygon in geometry["coordinates"]: + for coordinate in polygon[0]: + _submit_coordinate(coordinate) + + self.traverse_feature(_get_bounds) + return bounds + + @property + def center(self): + min_lon, max_lon, min_lat, max_lat = self.bounds + cx = (max_lon - min_lon) / 2.0 + cy = (max_lat - min_lat) / 2.0 + return min_lon + cx, min_lat + cy + + def on_geojson(self, instance, geojson, update=False): + if self.parent is None: + return + if not update: + self.g_canvas_polygon.clear() + self._geojson_part(geojson, geotype="Polygon") + self.canvas_line.clear() + self._geojson_part(geojson, geotype="LineString") + + def on_source(self, instance, value): + if value.startswith(("http://", "https://")): + Downloader.instance(cache_dir=self.cache_dir).download( + value, self._load_geojson_url + ) + else: + with open(value, "rb") as fd: + geojson = json.load(fd) + self.geojson = geojson + + def _load_geojson_url(self, url, response): + self.geojson = response.json() + + def _geojson_part(self, part, geotype=None): + tp = part["type"] + if tp == "FeatureCollection": + for feature in part["features"]: + if geotype and feature["geometry"]["type"] != geotype: + continue + self._geojson_part_f(feature) + elif tp == "Feature": + if geotype and part["geometry"]["type"] == geotype: + self._geojson_part_f(part) + else: + # unhandled geojson part + pass + + def _geojson_part_f(self, feature): + properties = feature["properties"] + geometry = feature["geometry"] + graphics = self._geojson_part_geometry(geometry, properties) + for g in graphics: + tp = geometry["type"] + if tp == "Polygon": + self.g_canvas_polygon.add(g) + else: + self.canvas_line.add(g) + + def _geojson_part_geometry(self, geometry, properties): + tp = geometry["type"] + graphics = [] + if tp == "Polygon": + tess = Tesselator() + for c in geometry["coordinates"]: + xy = list(self._lonlat_to_xy(c)) + xy = flatten(xy) + tess.add_contour(xy) + + tess.tesselate(WINDING_ODD, TYPE_POLYGONS) + + color = self._get_color_from(properties.get("color", "FF000088")) + graphics.append(Color(*color)) + for vertices, indices in tess.meshes: + graphics.append( + Mesh(vertices=vertices, indices=indices, mode="triangle_fan") + ) + + elif tp == "LineString": + stroke = get_color_from_hex(properties.get("stroke", "#ffffff")) + stroke_width = dp(properties.get("stroke-width")) + xy = list(self._lonlat_to_xy(geometry["coordinates"])) + xy = flatten(xy) + graphics.append(Color(*stroke)) + graphics.append(Line(points=xy, width=stroke_width)) + + return graphics + + def _lonlat_to_xy(self, lonlats): + view = self.parent + zoom = view.zoom + for lon, lat in lonlats: + p = view.get_window_xy_from(lat, lon, zoom) + p = p[0] - self.parent.delta_x, p[1] - self.parent.delta_y + p = self.parent._scatter.to_local(*p) + yield p + + def _get_color_from(self, value): + color = COLORS.get(value.lower(), value) + color = get_color_from_hex(color) + return color diff --git a/kivyblocks/mapview/icons/cluster.png b/kivyblocks/mapview/icons/cluster.png new file mode 100644 index 0000000000000000000000000000000000000000..a7047562f73b8ceafd89f8e97ce7909fb8949af9 GIT binary patch literal 600 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTCwj^(N7lwTxxOJ_=Y@i5dfk$L9 z0|U1(2s1Lwnj--eWH0gbb!C6Z#KAAjxS2=a5GW*@84^+AoS&PUnpXnkGB7w7r6!i7 zrYMwWmSiZnd-?{X=%unVFfi`%ba4#HxOaEf+M+`aBCY#%y2Kq40uMH>Ia;#paQA}* zt%)9L7I7^vdDyyH*txnNur8Vr@}~7>&?GUAlW}&^6N@+1?|hy&L50&&fa$%1@CwGz z1xzfTY#93-cw`!Q4cNE!@3W{l;2`^gQCF~r-*5xlvIF}xm=z5eq@JAS>&TOx8qM3& zXm&o8wdPsw0_FIjacl(Z_lNEXccVVnO^?ugjD0kSAV24RS z{h|G5jWau*ZElqN^{JM zIPr^vw&-IQc_~hwQ-(J#cxrbZR7;Dpl+qO1A|#y?wsLCoLAJF~Hzjhs)=b@bux+K( zbw{2Y|K|sy7B+5;zThJ&HDedsw;MHa+}0QWC{OJWk?hb5=1ehMFVYeJu%s|fFX~IJ zmh1I@Zu*f4*2?<~AA6nFTq`#N#NME?^y-w*3p}=l6K^I}{ZzZpc{Mjod~0u>_|ryxzJqd+BtW5Kshry85}Sb4q9e0Hd$tZvX%Q literal 0 HcmV?d00001 diff --git a/kivyblocks/mapview/icons/marker.png b/kivyblocks/mapview/icons/marker.png new file mode 100644 index 0000000000000000000000000000000000000000..282454035249dac5ed45a166c1f63504292ff16d GIT binary patch literal 4203 zcmeH~do)yQAIJAFgGRj-Vn{X3DVNGzBrnsnURi5OfI>T z%aKkgNl~d(ZXt9-eOS9d80!oAL4j1< zSmjk57GhIz?(3Y0PJC;S3)zJVz%8N9OjhVFmN^?|K|@o-WSIdEgnh7LUO=FbET-bV z`I2SN@?ioF`%MDxqT)Q9Hes!U1R&M~Z-OTp*@&SKV;l{Qr3ly@vMa;(yM^qHisQmC zpG+W#L?XOsEj~!#MEj!;8>ZrLvN-m;xt#nD_dwxy2r?W5d4xd16AAwz{=S{j;zG z17+2mNLIE@SJ200B_C2qw`Z!zT9^tu764FEdxjO0`>4~?F@Onbd}+NRT6$<0!0+Uq zRJ9{6-xQaIEkA~dmD1|#xluRTQ1?(6?*;Sb_$n-?cOVbwcyd!1ni#GGX=l)LG9Iy< zViFkx*1RFqW@SujL?&fR;f=lOaTQ0hZY|BS*3J}0rrb$L=~C3ODT&W>ZlvDP$W=$_FDrOy zeZY6757yZ`fndMZrs`@hY-o$}=%FuD*F#0AOjBC*Z_OMdWOyn+p>|QerN_SEdELLD zHj$zG`6j%%-?(nf+H3*;f+cXf`Njb{Jx-Opx2<#90W@qxr6{gqSgz5X`wVQ+%T14L81-nUmP+xi;807 zs{H^;EE!tlk_}tj-0IPA1fc(H|JkBfGoGM{b&5{@lj$npNDR3DZgpM2ib&n#r@P6y z6W8n`&KYexm>JZOJ!-4wBHX%>GvZbFcv0`!Xc7Bn+2~PNON;)&_NiC5`8yiv5!u}U zVSzedbFm=@Sgn^ahKP(*Mn+;l^_{j?+bilGY}@ylQ%O16yFyuRvHooYDmwEk3< zB6BFOV@EV)2csQgv}>l7Q#*X9&Et#pimUVMqK|KO9C_?!Y04aM4X-W<&WQ?PmVHw5 zc`#6IF?zcDyo1Z_85L4~N%RCdv7=nHPV@Z!g!}dH9S|L#uPCFpxoh&A&n@ytvS(NU-` zW&@3XGCU(Wx38~y?#H6>hW2;sDl7La(@_BUr(WJau?q7xxd4stEvwwUC~6i8D&h3UMUVfs+?ZboHLww6+)OmhzG*V{hgTzNefy#jvee6d&tU9OSlB z1=d=(q|YyV_;%`%eFT4U#U08dQt9@wE1I}#`6~vKM$g@Mqr2OvY=p+D3>mMa^$yVX zzWLXKsK!X(&-bB%)f(aS@eO92z{Y~u{QJ^_`Q=T|9bQh&zapjuzcmWBkpGq2+c+~y I={uwT0;Ra9Q~&?~ literal 0 HcmV?d00001 diff --git a/kivyblocks/mapview/mbtsource.py b/kivyblocks/mapview/mbtsource.py new file mode 100644 index 0000000..d11fe39 --- /dev/null +++ b/kivyblocks/mapview/mbtsource.py @@ -0,0 +1,121 @@ +# coding=utf-8 +""" +MBTiles provider for MapView +============================ + +This provider is based on .mbfiles from MapBox. +See: http://mbtiles.org/ +""" + +__all__ = ["MBTilesMapSource"] + + +import io +import sqlite3 +import threading + +from kivy.core.image import Image as CoreImage +from kivy.core.image import ImageLoader + +from kivyblocks.mapview.downloader import Downloader +from kivyblocks.mapview.source import MapSource + + +class MBTilesMapSource(MapSource): + def __init__(self, filename, **kwargs): + super().__init__(**kwargs) + self.filename = filename + self.db = sqlite3.connect(filename) + + # read metadata + c = self.db.cursor() + metadata = dict(c.execute("SELECT * FROM metadata")) + if metadata["format"] == "pbf": + raise ValueError("Only raster maps are supported, not vector maps.") + self.min_zoom = int(metadata["minzoom"]) + self.max_zoom = int(metadata["maxzoom"]) + self.attribution = metadata.get("attribution", "") + self.bounds = bounds = None + cx = cy = 0.0 + cz = 5 + if "bounds" in metadata: + self.bounds = bounds = map(float, metadata["bounds"].split(",")) + if "center" in metadata: + cx, cy, cz = map(float, metadata["center"].split(",")) + elif self.bounds: + cx = (bounds[2] + bounds[0]) / 2.0 + cy = (bounds[3] + bounds[1]) / 2.0 + cz = self.min_zoom + self.default_lon = cx + self.default_lat = cy + self.default_zoom = int(cz) + self.projection = metadata.get("projection", "") + self.is_xy = self.projection == "xy" + + def fill_tile(self, tile): + if tile.state == "done": + return + Downloader.instance(self.cache_dir).submit(self._load_tile, tile) + + def _load_tile(self, tile): + # global db context cannot be shared across threads. + ctx = threading.local() + if not hasattr(ctx, "db"): + ctx.db = sqlite3.connect(self.filename) + + # get the right tile + c = ctx.db.cursor() + c.execute( + ( + "SELECT tile_data FROM tiles WHERE " + "zoom_level=? AND tile_column=? AND tile_row=?" + ), + (tile.zoom, tile.tile_x, tile.tile_y), + ) + row = c.fetchone() + if not row: + tile.state = "done" + return + + # no-file loading + try: + data = io.BytesIO(row[0]) + except Exception: + # android issue, "buffer" does not have the buffer interface + # ie row[0] buffer is not compatible with BytesIO on Android?? + data = io.BytesIO(bytes(row[0])) + im = CoreImage( + data, + ext='png', + filename="{}.{}.{}.png".format(tile.zoom, tile.tile_x, tile.tile_y), + ) + + if im is None: + tile.state = "done" + return + + return self._load_tile_done, (tile, im,) + + def _load_tile_done(self, tile, im): + tile.texture = im.texture + tile.state = "need-animation" + + def get_x(self, zoom, lon): + if self.is_xy: + return lon + return super().get_x(zoom, lon) + + def get_y(self, zoom, lat): + if self.is_xy: + return lat + return super().get_y(zoom, lat) + + def get_lon(self, zoom, x): + if self.is_xy: + return x + return super().get_lon(zoom, x) + + def get_lat(self, zoom, y): + if self.is_xy: + return y + return super().get_lat(zoom, y) diff --git a/kivyblocks/mapview/source.py b/kivyblocks/mapview/source.py new file mode 100644 index 0000000..059f7c5 --- /dev/null +++ b/kivyblocks/mapview/source.py @@ -0,0 +1,212 @@ +# coding=utf-8 + +__all__ = ["MapSource"] + +import hashlib +from math import atan, ceil, cos, exp, log, pi, tan + +from kivy.metrics import dp + +from kivyblocks.mapview.constants import ( + CACHE_DIR, + MAX_LATITUDE, + MAX_LONGITUDE, + MIN_LATITUDE, + MIN_LONGITUDE, +) +from kivyblocks.mapview.downloader import Downloader +from kivyblocks.mapview.utils import clamp + + +class MapSource: + """Base class for implementing a map source / provider + """ + + attribution_osm = 'Maps & Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]' + attribution_thunderforest = 'Maps © [i][ref=http://www.thunderforest.com]Thunderforest[/ref][/i], Data © [i][ref=http://www.osm.org/copyright]OpenStreetMap contributors[/ref][/i]' + + # list of available providers + # cache_key: (is_overlay, minzoom, maxzoom, url, attribution) + providers = { + "osm": ( + 0, + 0, + 19, + "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution_osm, + ), + "osm-hot": ( + 0, + 0, + 19, + "http://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", + "", + ), + "osm-de": ( + 0, + 0, + 18, + "http://{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png", + "Tiles @ OSM DE", + ), + "osm-fr": ( + 0, + 0, + 20, + "http://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png", + "Tiles @ OSM France", + ), + "cyclemap": ( + 0, + 0, + 17, + "http://{s}.tile.opencyclemap.org/cycle/{z}/{x}/{y}.png", + "Tiles @ Andy Allan", + ), + "thunderforest-cycle": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png", + attribution_thunderforest, + ), + "thunderforest-transport": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/transport/{z}/{x}/{y}.png", + attribution_thunderforest, + ), + "thunderforest-landscape": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/landscape/{z}/{x}/{y}.png", + attribution_thunderforest, + ), + "thunderforest-outdoors": ( + 0, + 0, + 19, + "http://{s}.tile.thunderforest.com/outdoors/{z}/{x}/{y}.png", + attribution_thunderforest, + ), + # no longer available + # "mapquest-osm": (0, 0, 19, "http://otile{s}.mqcdn.com/tiles/1.0.0/map/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}), + # "mapquest-aerial": (0, 0, 19, "http://oatile{s}.mqcdn.com/tiles/1.0.0/sat/{z}/{x}/{y}.jpeg", "Tiles Courtesy of Mapquest", {"subdomains": "1234", "image_ext": "jpeg"}), + # more to add with + # https://github.com/leaflet-extras/leaflet-providers/blob/master/leaflet-providers.js + # not working ? + # "openseamap": (0, 0, 19, "http://tiles.openseamap.org/seamark/{z}/{x}/{y}.png", + # "Map data @ OpenSeaMap contributors"), + } + + def __init__( + self, + url="http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + cache_key=None, + min_zoom=0, + max_zoom=19, + tile_size=256, + image_ext="png", + attribution="© OpenStreetMap contributors", + subdomains="abc", + **kwargs + ): + if cache_key is None: + # possible cache hit, but very unlikely + cache_key = hashlib.sha224(url.encode("utf8")).hexdigest()[:10] + self.url = url + self.cache_key = cache_key + self.min_zoom = min_zoom + self.max_zoom = max_zoom + self.tile_size = tile_size + self.image_ext = image_ext + self.attribution = attribution + self.subdomains = subdomains + self.cache_fmt = "{cache_key}_{zoom}_{tile_x}_{tile_y}.{image_ext}" + self.dp_tile_size = min(dp(self.tile_size), self.tile_size * 2) + self.default_lat = self.default_lon = self.default_zoom = None + self.bounds = None + self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) + + @staticmethod + def from_provider(key, **kwargs): + provider = MapSource.providers[key] + cache_dir = kwargs.get('cache_dir', CACHE_DIR) + options = {} + is_overlay, min_zoom, max_zoom, url, attribution = provider[:5] + if len(provider) > 5: + options = provider[5] + return MapSource( + cache_key=key, + min_zoom=min_zoom, + max_zoom=max_zoom, + url=url, + cache_dir=cache_dir, + attribution=attribution, + **options + ) + + def get_x(self, zoom, lon): + """Get the x position on the map using this map source's projection + (0, 0) is located at the top left. + """ + lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) + return ((lon + 180.0) / 360.0 * pow(2.0, zoom)) * self.dp_tile_size + + def get_y(self, zoom, lat): + """Get the y position on the map using this map source's projection + (0, 0) is located at the top left. + """ + lat = clamp(-lat, MIN_LATITUDE, MAX_LATITUDE) + lat = lat * pi / 180.0 + return ( + (1.0 - log(tan(lat) + 1.0 / cos(lat)) / pi) / 2.0 * pow(2.0, zoom) + ) * self.dp_tile_size + + def get_lon(self, zoom, x): + """Get the longitude to the x position in the map source's projection + """ + dx = x / float(self.dp_tile_size) + lon = dx / pow(2.0, zoom) * 360.0 - 180.0 + return clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) + + def get_lat(self, zoom, y): + """Get the latitude to the y position in the map source's projection + """ + dy = y / float(self.dp_tile_size) + n = pi - 2 * pi * dy / pow(2.0, zoom) + lat = -180.0 / pi * atan(0.5 * (exp(n) - exp(-n))) + return clamp(lat, MIN_LATITUDE, MAX_LATITUDE) + + def get_row_count(self, zoom): + """Get the number of tiles in a row at this zoom level + """ + if zoom == 0: + return 1 + return 2 << (zoom - 1) + + def get_col_count(self, zoom): + """Get the number of tiles in a col at this zoom level + """ + if zoom == 0: + return 1 + return 2 << (zoom - 1) + + def get_min_zoom(self): + """Return the minimum zoom of this source + """ + return self.min_zoom + + def get_max_zoom(self): + """Return the maximum zoom of this source + """ + return self.max_zoom + + def fill_tile(self, tile): + """Add this tile to load within the downloader + """ + if tile.state == "done": + return + Downloader.instance(cache_dir=self.cache_dir).download_tile(tile) diff --git a/kivyblocks/mapview/types.py b/kivyblocks/mapview/types.py new file mode 100644 index 0000000..622d8a9 --- /dev/null +++ b/kivyblocks/mapview/types.py @@ -0,0 +1,29 @@ +# coding=utf-8 + +__all__ = ["Coordinate", "Bbox"] + +from collections import namedtuple + +Coordinate = namedtuple("Coordinate", ["lat", "lon"]) + + +class Bbox(tuple): + def collide(self, *args): + if isinstance(args[0], Coordinate): + coord = args[0] + lat = coord.lat + lon = coord.lon + else: + lat, lon = args + lat1, lon1, lat2, lon2 = self[:] + + if lat1 < lat2: + in_lat = lat1 <= lat <= lat2 + else: + in_lat = lat2 <= lat <= lat2 + if lon1 < lon2: + in_lon = lon1 <= lon <= lon2 + else: + in_lon = lon2 <= lon <= lon2 + + return in_lat and in_lon diff --git a/kivyblocks/mapview/utils.py b/kivyblocks/mapview/utils.py new file mode 100644 index 0000000..1ecc84f --- /dev/null +++ b/kivyblocks/mapview/utils.py @@ -0,0 +1,50 @@ +# coding=utf-8 + +__all__ = ["clamp", "haversine", "get_zoom_for_radius"] + +from math import asin, cos, pi, radians, sin, sqrt + +from kivy.core.window import Window +from kivy.metrics import dp + + +def clamp(x, minimum, maximum): + return max(minimum, min(x, maximum)) + + +def haversine(lon1, lat1, lon2, lat2): + """ + Calculate the great circle distance between two points + on the earth (specified in decimal degrees) + + Taken from: http://stackoverflow.com/questions/4913349/haversine-formula-in-python-bearing-and-distance-between-two-gps-points + """ + # convert decimal degrees to radians + lon1, lat1, lon2, lat2 = map(radians, [lon1, lat1, lon2, lat2]) + # haversine formula + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 + + c = 2 * asin(sqrt(a)) + km = 6367 * c + return km + + +def get_zoom_for_radius(radius_km, lat=None, tile_size=256.0): + """See: https://wiki.openstreetmap.org/wiki/Zoom_levels""" + radius = radius_km * 1000.0 + if lat is None: + lat = 0.0 # Do not compensate for the latitude + + # Calculate the equatorial circumference based on the WGS-84 radius + earth_circumference = 2.0 * pi * 6378137.0 * cos(lat * pi / 180.0) + + # Check how many tiles that are currently in view + nr_tiles_shown = min(Window.size) / dp(tile_size) + + # Keep zooming in until we find a zoom level where the circle can fit inside the screen + zoom = 1 + while earth_circumference / (2 << (zoom - 1)) * nr_tiles_shown > 2 * radius: + zoom += 1 + return zoom - 1 # Go one zoom level back diff --git a/kivyblocks/mapview/view.py b/kivyblocks/mapview/view.py new file mode 100644 index 0000000..ae56423 --- /dev/null +++ b/kivyblocks/mapview/view.py @@ -0,0 +1,999 @@ +# coding=utf-8 + +__all__ = ["MapView", "MapMarker", "MapMarkerPopup", "MapLayer", "MarkerMapLayer"] + +import webbrowser +from itertools import takewhile +from math import ceil +from os.path import dirname, join + +from kivy.clock import Clock +from kivy.compat import string_types +from kivy.graphics import Canvas, Color, Rectangle +from kivy.graphics.transformation import Matrix +from kivy.lang import Builder +from kivy.metrics import dp +from kivy.properties import ( + AliasProperty, + BooleanProperty, + ListProperty, + NumericProperty, + ObjectProperty, + StringProperty, +) +from kivy.uix.behaviors import ButtonBehavior +from kivy.uix.image import Image +from kivy.uix.label import Label +from kivy.uix.scatter import Scatter +from kivy.uix.widget import Widget + +from kivyblocks.mapview import Bbox, Coordinate +from kivyblocks.mapview.constants import ( + CACHE_DIR, + MAX_LATITUDE, + MAX_LONGITUDE, + MIN_LATITUDE, + MIN_LONGITUDE, +) +from kivyblocks.mapview.source import MapSource +from kivyblocks.mapview.utils import clamp + +Builder.load_string( + """ +: + size_hint: None, None + source: root.source + size: list(map(dp, self.texture_size)) + allow_stretch: True + +: + canvas.before: + StencilPush + Rectangle: + pos: self.pos + size: self.size + StencilUse + Color: + rgba: self.background_color + Rectangle: + pos: self.pos + size: self.size + canvas.after: + StencilUnUse + Rectangle: + pos: self.pos + size: self.size + StencilPop + + ClickableLabel: + text: root.map_source.attribution if hasattr(root.map_source, "attribution") else "" + size_hint: None, None + size: self.texture_size[0] + sp(8), self.texture_size[1] + sp(4) + font_size: "10sp" + right: [root.right, self.center][0] + color: 0, 0, 0, 1 + markup: True + canvas.before: + Color: + rgba: .8, .8, .8, .8 + Rectangle: + pos: self.pos + size: self.size + + +: + auto_bring_to_front: False + do_rotation: False + scale_min: 0.2 + scale_max: 3. + +: + RelativeLayout: + id: placeholder + y: root.top + center_x: root.center_x + size: root.popup_size + +""" +) + + +class ClickableLabel(Label): + def on_ref_press(self, *args): + webbrowser.open(str(args[0]), new=2) + + +class Tile(Rectangle): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.cache_dir = kwargs.get('cache_dir', CACHE_DIR) + + @property + def cache_fn(self): + map_source = self.map_source + fn = map_source.cache_fmt.format( + image_ext=map_source.image_ext, + cache_key=map_source.cache_key, + **self.__dict__ + ) + return join(self.cache_dir, fn) + + def set_source(self, cache_fn): + self.source = cache_fn + self.state = "need-animation" + + +class MapMarker(ButtonBehavior, Image): + """A marker on a map, that must be used on a :class:`MapMarker` + """ + + anchor_x = NumericProperty(0.5) + """Anchor of the marker on the X axis. Defaults to 0.5, mean the anchor will + be at the X center of the image. + """ + + anchor_y = NumericProperty(0) + """Anchor of the marker on the Y axis. Defaults to 0, mean the anchor will + be at the Y bottom of the image. + """ + + lat = NumericProperty(0) + """Latitude of the marker + """ + + lon = NumericProperty(0) + """Longitude of the marker + """ + + source = StringProperty(join(dirname(__file__), "icons", "marker.png")) + """Source of the marker, defaults to our own marker.png + """ + + # (internal) reference to its layer + _layer = None + + def detach(self): + if self._layer: + self._layer.remove_widget(self) + self._layer = None + + +class MapMarkerPopup(MapMarker): + is_open = BooleanProperty(False) + placeholder = ObjectProperty(None) + popup_size = ListProperty([100, 100]) + + def add_widget(self, widget): + if not self.placeholder: + self.placeholder = widget + if self.is_open: + super().add_widget(self.placeholder) + else: + self.placeholder.add_widget(widget) + + def remove_widget(self, widget): + if widget is not self.placeholder: + self.placeholder.remove_widget(widget) + else: + super().remove_widget(widget) + + def on_is_open(self, *args): + self.refresh_open_status() + + def on_release(self, *args): + self.is_open = not self.is_open + + def refresh_open_status(self): + if not self.is_open and self.placeholder.parent: + super().remove_widget(self.placeholder) + elif self.is_open and not self.placeholder.parent: + super().add_widget(self.placeholder) + + +class MapLayer(Widget): + """A map layer, that is repositionned everytime the :class:`MapView` is + moved. + """ + + viewport_x = NumericProperty(0) + viewport_y = NumericProperty(0) + + def reposition(self): + """Function called when :class:`MapView` is moved. You must recalculate + the position of your children. + """ + pass + + def unload(self): + """Called when the view want to completly unload the layer. + """ + pass + + +class MarkerMapLayer(MapLayer): + """A map layer for :class:`MapMarker` + """ + + order_marker_by_latitude = BooleanProperty(True) + + def __init__(self, **kwargs): + self.markers = [] + super().__init__(**kwargs) + + def insert_marker(self, marker, **kwargs): + if self.order_marker_by_latitude: + before = list( + takewhile(lambda i_m: i_m[1].lat < marker.lat, enumerate(self.children)) + ) + if before: + kwargs['index'] = before[-1][0] + 1 + + super().add_widget(marker, **kwargs) + + def add_widget(self, marker): + marker._layer = self + self.markers.append(marker) + self.insert_marker(marker) + + def remove_widget(self, marker): + marker._layer = None + if marker in self.markers: + self.markers.remove(marker) + super().remove_widget(marker) + + def reposition(self): + if not self.markers: + return + mapview = self.parent + set_marker_position = self.set_marker_position + bbox = None + # reposition the markers depending the latitude + markers = sorted(self.markers, key=lambda x: -x.lat) + margin = max((max(marker.size) for marker in markers)) + bbox = mapview.get_bbox(margin) + for marker in markers: + if bbox.collide(marker.lat, marker.lon): + set_marker_position(mapview, marker) + if not marker.parent: + self.insert_marker(marker) + else: + super().remove_widget(marker) + + def set_marker_position(self, mapview, marker): + x, y = mapview.get_window_xy_from(marker.lat, marker.lon, mapview.zoom) + marker.x = int(x - marker.width * marker.anchor_x) + marker.y = int(y - marker.height * marker.anchor_y) + + def unload(self): + self.clear_widgets() + del self.markers[:] + + +class MapViewScatter(Scatter): + # internal + def on_transform(self, *args): + super().on_transform(*args) + self.parent.on_transform(self.transform) + + def collide_point(self, x, y): + return True + + +class MapView(Widget): + """MapView is the widget that control the map displaying, navigation, and + layers management. + """ + + lon = NumericProperty() + """Longitude at the center of the widget + """ + + lat = NumericProperty() + """Latitude at the center of the widget + """ + + zoom = NumericProperty(0) + """Zoom of the widget. Must be between :meth:`MapSource.get_min_zoom` and + :meth:`MapSource.get_max_zoom`. Default to 0. + """ + + map_source = ObjectProperty(MapSource()) + """Provider of the map, default to a empty :class:`MapSource`. + """ + + double_tap_zoom = BooleanProperty(False) + """If True, this will activate the double-tap to zoom. + """ + + pause_on_action = BooleanProperty(True) + """Pause any map loading / tiles loading when an action is done. + This allow better performance on mobile, but can be safely deactivated on + desktop. + """ + + snap_to_zoom = BooleanProperty(True) + """When the user initiate a zoom, it will snap to the closest zoom for + better graphics. The map can be blur if the map is scaled between 2 zoom. + Default to True, even if it doesn't fully working yet. + """ + + animation_duration = NumericProperty(100) + """Duration to animate Tiles alpha from 0 to 1 when it's ready to show. + Default to 100 as 100ms. Use 0 to deactivate. + """ + + delta_x = NumericProperty(0) + delta_y = NumericProperty(0) + background_color = ListProperty([181 / 255.0, 208 / 255.0, 208 / 255.0, 1]) + cache_dir = StringProperty(CACHE_DIR) + _zoom = NumericProperty(0) + _pause = BooleanProperty(False) + _scale = 1.0 + _disabled_count = 0 + + __events__ = ["on_map_relocated"] + + # Public API + + @property + def viewport_pos(self): + vx, vy = self._scatter.to_local(self.x, self.y) + return vx - self.delta_x, vy - self.delta_y + + @property + def scale(self): + if self._invalid_scale: + self._invalid_scale = False + self._scale = self._scatter.scale + return self._scale + + def get_bbox(self, margin=0): + """Returns the bounding box from the bottom/left (lat1, lon1) to + top/right (lat2, lon2). + """ + x1, y1 = self.to_local(0 - margin, 0 - margin) + x2, y2 = self.to_local((self.width + margin), (self.height + margin)) + c1 = self.get_latlon_at(x1, y1) + c2 = self.get_latlon_at(x2, y2) + return Bbox((c1.lat, c1.lon, c2.lat, c2.lon)) + + bbox = AliasProperty(get_bbox, None, bind=["lat", "lon", "_zoom"]) + + def unload(self): + """Unload the view and all the layers. + It also cancel all the remaining downloads. + """ + self.remove_all_tiles() + + def get_window_xy_from(self, lat, lon, zoom): + """Returns the x/y position in the widget absolute coordinates + from a lat/lon""" + scale = self.scale + vx, vy = self.viewport_pos + ms = self.map_source + x = ms.get_x(zoom, lon) - vx + y = ms.get_y(zoom, lat) - vy + x *= scale + y *= scale + x = x + self.pos[0] + y = y + self.pos[1] + return x, y + + def center_on(self, *args): + """Center the map on the coordinate :class:`Coordinate`, or a (lat, lon) + """ + map_source = self.map_source + zoom = self._zoom + + if len(args) == 1 and isinstance(args[0], Coordinate): + coord = args[0] + lat = coord.lat + lon = coord.lon + elif len(args) == 2: + lat, lon = args + else: + raise Exception("Invalid argument for center_on") + lon = clamp(lon, MIN_LONGITUDE, MAX_LONGITUDE) + lat = clamp(lat, MIN_LATITUDE, MAX_LATITUDE) + scale = self._scatter.scale + x = map_source.get_x(zoom, lon) - self.center_x / scale + y = map_source.get_y(zoom, lat) - self.center_y / scale + self.delta_x = -x + self.delta_y = -y + self.lon = lon + self.lat = lat + self._scatter.pos = 0, 0 + self.trigger_update(True) + + def set_zoom_at(self, zoom, x, y, scale=None): + """Sets the zoom level, leaving the (x, y) at the exact same point + in the view. + """ + zoom = clamp( + zoom, self.map_source.get_min_zoom(), self.map_source.get_max_zoom() + ) + if int(zoom) == int(self._zoom): + if scale is None: + return + elif scale == self.scale: + return + scale = scale or 1.0 + + # first, rescale the scatter + scatter = self._scatter + scale = clamp(scale, scatter.scale_min, scatter.scale_max) + rescale = scale * 1.0 / scatter.scale + scatter.apply_transform( + Matrix().scale(rescale, rescale, rescale), + post_multiply=True, + anchor=scatter.to_local(x, y), + ) + + # adjust position if the zoom changed + c1 = self.map_source.get_col_count(self._zoom) + c2 = self.map_source.get_col_count(zoom) + if c1 != c2: + f = float(c2) / float(c1) + self.delta_x = scatter.x + self.delta_x * f + self.delta_y = scatter.y + self.delta_y * f + # back to 0 every time + scatter.apply_transform( + Matrix().translate(-scatter.x, -scatter.y, 0), post_multiply=True + ) + + # avoid triggering zoom changes. + self._zoom = zoom + self.zoom = self._zoom + + def on_zoom(self, instance, zoom): + if zoom == self._zoom: + return + x = self.map_source.get_x(zoom, self.lon) - self.delta_x + y = self.map_source.get_y(zoom, self.lat) - self.delta_y + self.set_zoom_at(zoom, x, y) + self.center_on(self.lat, self.lon) + + def get_latlon_at(self, x, y, zoom=None): + """Return the current :class:`Coordinate` within the (x, y) widget + coordinate. + """ + if zoom is None: + zoom = self._zoom + vx, vy = self.viewport_pos + scale = self._scale + return Coordinate( + lat=self.map_source.get_lat(zoom, y / scale + vy), + lon=self.map_source.get_lon(zoom, x / scale + vx), + ) + + def add_marker(self, marker, layer=None): + """Add a marker into the layer. If layer is None, it will be added in + the default marker layer. If there is no default marker layer, a new + one will be automatically created + """ + if layer is None: + if not self._default_marker_layer: + layer = MarkerMapLayer() + self.add_layer(layer) + else: + layer = self._default_marker_layer + layer.add_widget(marker) + layer.set_marker_position(self, marker) + + def remove_marker(self, marker): + """Remove a marker from its layer + """ + marker.detach() + + def add_layer(self, layer, mode="window"): + """Add a new layer to update at the same time the base tile layer. + mode can be either "scatter" or "window". If "scatter", it means the + layer will be within the scatter transformation. It's perfect if you + want to display path / shape, but not for text. + If "window", it will have no transformation. You need to position the + widget yourself: think as Z-sprite / billboard. + Defaults to "window". + """ + assert mode in ("scatter", "window") + if self._default_marker_layer is None and isinstance(layer, MarkerMapLayer): + self._default_marker_layer = layer + self._layers.append(layer) + c = self.canvas + if mode == "scatter": + self.canvas = self.canvas_layers + else: + self.canvas = self.canvas_layers_out + layer.canvas_parent = self.canvas + super().add_widget(layer) + self.canvas = c + + def remove_layer(self, layer): + """Remove the layer + """ + c = self.canvas + self._layers.remove(layer) + self.canvas = layer.canvas_parent + super().remove_widget(layer) + self.canvas = c + + def sync_to(self, other): + """Reflect the lat/lon/zoom of the other MapView to the current one. + """ + if self._zoom != other._zoom: + self.set_zoom_at(other._zoom, *self.center) + self.center_on(other.get_latlon_at(*self.center)) + + # Private API + + def __init__(self, **kwargs): + from kivy.base import EventLoop + + EventLoop.ensure_window() + self._invalid_scale = True + self._tiles = [] + self._tiles_bg = [] + self._tilemap = {} + self._layers = [] + self._default_marker_layer = None + self._need_redraw_all = False + self._transform_lock = False + self.trigger_update(True) + self.canvas = Canvas() + self._scatter = MapViewScatter() + self.add_widget(self._scatter) + with self._scatter.canvas: + self.canvas_map = Canvas() + self.canvas_layers = Canvas() + with self.canvas: + self.canvas_layers_out = Canvas() + self._scale_target_anim = False + self._scale_target = 1.0 + self._touch_count = 0 + self.map_source.cache_dir = self.cache_dir + Clock.schedule_interval(self._animate_color, 1 / 60.0) + self.lat = kwargs.get("lat", self.lat) + self.lon = kwargs.get("lon", self.lon) + super().__init__(**kwargs) + + def _animate_color(self, dt): + # fast path + d = self.animation_duration + if d == 0: + for tile in self._tiles: + if tile.state == "need-animation": + tile.g_color.a = 1.0 + tile.state = "animated" + for tile in self._tiles_bg: + if tile.state == "need-animation": + tile.g_color.a = 1.0 + tile.state = "animated" + else: + d = d / 1000.0 + for tile in self._tiles: + if tile.state != "need-animation": + continue + tile.g_color.a += dt / d + if tile.g_color.a >= 1: + tile.state = "animated" + for tile in self._tiles_bg: + if tile.state != "need-animation": + continue + tile.g_color.a += dt / d + if tile.g_color.a >= 1: + tile.state = "animated" + + def add_widget(self, widget): + if isinstance(widget, MapMarker): + self.add_marker(widget) + elif isinstance(widget, MapLayer): + self.add_layer(widget) + else: + super().add_widget(widget) + + def remove_widget(self, widget): + if isinstance(widget, MapMarker): + self.remove_marker(widget) + elif isinstance(widget, MapLayer): + self.remove_layer(widget) + else: + super().remove_widget(widget) + + def on_map_relocated(self, zoom, coord): + pass + + def animated_diff_scale_at(self, d, x, y): + self._scale_target_time = 1.0 + self._scale_target_pos = x, y + if self._scale_target_anim is False: + self._scale_target_anim = True + self._scale_target = d + else: + self._scale_target += d + Clock.unschedule(self._animate_scale) + Clock.schedule_interval(self._animate_scale, 1 / 60.0) + + def _animate_scale(self, dt): + diff = self._scale_target / 3.0 + if abs(diff) < 0.01: + diff = self._scale_target + self._scale_target = 0 + else: + self._scale_target -= diff + self._scale_target_time -= dt + self.diff_scale_at(diff, *self._scale_target_pos) + ret = self._scale_target != 0 + if not ret: + self._pause = False + return ret + + def diff_scale_at(self, d, x, y): + scatter = self._scatter + scale = scatter.scale * (2 ** d) + self.scale_at(scale, x, y) + + def scale_at(self, scale, x, y): + scatter = self._scatter + scale = clamp(scale, scatter.scale_min, scatter.scale_max) + rescale = scale * 1.0 / scatter.scale + scatter.apply_transform( + Matrix().scale(rescale, rescale, rescale), + post_multiply=True, + anchor=scatter.to_local(x, y), + ) + + def on_touch_down(self, touch): + if not self.collide_point(*touch.pos): + return + if self.pause_on_action: + self._pause = True + if "button" in touch.profile and touch.button in ("scrolldown", "scrollup"): + d = 1 if touch.button == "scrollup" else -1 + self.animated_diff_scale_at(d, *touch.pos) + return True + elif touch.is_double_tap and self.double_tap_zoom: + self.animated_diff_scale_at(1, *touch.pos) + return True + touch.grab(self) + self._touch_count += 1 + if self._touch_count == 1: + self._touch_zoom = (self.zoom, self._scale) + return super().on_touch_down(touch) + + def on_touch_up(self, touch): + if touch.grab_current == self: + touch.ungrab(self) + self._touch_count -= 1 + if self._touch_count == 0: + # animate to the closest zoom + zoom, scale = self._touch_zoom + cur_zoom = self.zoom + cur_scale = self._scale + if cur_zoom < zoom or cur_scale < scale: + self.animated_diff_scale_at(1.0 - cur_scale, *touch.pos) + elif cur_zoom > zoom or cur_scale > scale: + self.animated_diff_scale_at(2.0 - cur_scale, *touch.pos) + self._pause = False + return True + return super().on_touch_up(touch) + + def on_transform(self, *args): + self._invalid_scale = True + if self._transform_lock: + return + self._transform_lock = True + # recalculate viewport + map_source = self.map_source + zoom = self._zoom + scatter = self._scatter + scale = scatter.scale + if scale >= 2.0: + zoom += 1 + scale /= 2.0 + elif scale < 1: + zoom -= 1 + scale *= 2.0 + zoom = clamp(zoom, map_source.min_zoom, map_source.max_zoom) + if zoom != self._zoom: + self.set_zoom_at(zoom, scatter.x, scatter.y, scale=scale) + self.trigger_update(True) + else: + if zoom == map_source.min_zoom and scatter.scale < 1.0: + scatter.scale = 1.0 + self.trigger_update(True) + else: + self.trigger_update(False) + + if map_source.bounds: + self._apply_bounds() + self._transform_lock = False + self._scale = self._scatter.scale + + def _apply_bounds(self): + # if the map_source have any constraints, apply them here. + map_source = self.map_source + zoom = self._zoom + min_lon, min_lat, max_lon, max_lat = map_source.bounds + xmin = map_source.get_x(zoom, min_lon) + xmax = map_source.get_x(zoom, max_lon) + ymin = map_source.get_y(zoom, min_lat) + ymax = map_source.get_y(zoom, max_lat) + + dx = self.delta_x + dy = self.delta_y + oxmin, oymin = self._scatter.to_local(self.x, self.y) + oxmax, oymax = self._scatter.to_local(self.right, self.top) + s = self._scale + cxmin = oxmin - dx + if cxmin < xmin: + self._scatter.x += (cxmin - xmin) * s + cymin = oymin - dy + if cymin < ymin: + self._scatter.y += (cymin - ymin) * s + cxmax = oxmax - dx + if cxmax > xmax: + self._scatter.x -= (xmax - cxmax) * s + cymax = oymax - dy + if cymax > ymax: + self._scatter.y -= (ymax - cymax) * s + + def on__pause(self, instance, value): + if not value: + self.trigger_update(True) + + def trigger_update(self, full): + self._need_redraw_full = full or self._need_redraw_full + Clock.unschedule(self.do_update) + Clock.schedule_once(self.do_update, -1) + + def do_update(self, dt): + zoom = self._zoom + scale = self._scale + self.lon = self.map_source.get_lon( + zoom, (self.center_x - self._scatter.x) / scale - self.delta_x + ) + self.lat = self.map_source.get_lat( + zoom, (self.center_y - self._scatter.y) / scale - self.delta_y + ) + self.dispatch("on_map_relocated", zoom, Coordinate(self.lon, self.lat)) + for layer in self._layers: + layer.reposition() + + if self._need_redraw_full: + self._need_redraw_full = False + self.move_tiles_to_background() + self.load_visible_tiles() + else: + self.load_visible_tiles() + + def bbox_for_zoom(self, vx, vy, w, h, zoom): + # return a tile-bbox for the zoom + map_source = self.map_source + size = map_source.dp_tile_size + scale = self._scale + + max_x_end = map_source.get_col_count(zoom) + max_y_end = map_source.get_row_count(zoom) + + x_count = int(ceil(w / scale / float(size))) + 1 + y_count = int(ceil(h / scale / float(size))) + 1 + + tile_x_first = int(clamp(vx / float(size), 0, max_x_end)) + tile_y_first = int(clamp(vy / float(size), 0, max_y_end)) + tile_x_last = tile_x_first + x_count + tile_y_last = tile_y_first + y_count + tile_x_last = int(clamp(tile_x_last, tile_x_first, max_x_end)) + tile_y_last = int(clamp(tile_y_last, tile_y_first, max_y_end)) + + x_count = tile_x_last - tile_x_first + y_count = tile_y_last - tile_y_first + return (tile_x_first, tile_y_first, tile_x_last, tile_y_last, x_count, y_count) + + def load_visible_tiles(self): + map_source = self.map_source + vx, vy = self.viewport_pos + zoom = self._zoom + dirs = [0, 1, 0, -1, 0] + bbox_for_zoom = self.bbox_for_zoom + size = map_source.dp_tile_size + + ( + tile_x_first, + tile_y_first, + tile_x_last, + tile_y_last, + x_count, + y_count, + ) = bbox_for_zoom(vx, vy, self.width, self.height, zoom) + + # Adjust tiles behind us + for tile in self._tiles_bg[:]: + tile_x = tile.tile_x + tile_y = tile.tile_y + + f = 2 ** (zoom - tile.zoom) + w = self.width / f + h = self.height / f + ( + btile_x_first, + btile_y_first, + btile_x_last, + btile_y_last, + _, + _, + ) = bbox_for_zoom(vx / f, vy / f, w, h, tile.zoom) + + if ( + tile_x < btile_x_first + or tile_x >= btile_x_last + or tile_y < btile_y_first + or tile_y >= btile_y_last + ): + tile.state = "done" + self._tiles_bg.remove(tile) + self.canvas_map.before.remove(tile.g_color) + self.canvas_map.before.remove(tile) + continue + + tsize = size * f + tile.size = tsize, tsize + tile.pos = (tile_x * tsize + self.delta_x, tile_y * tsize + self.delta_y) + + # Get rid of old tiles first + for tile in self._tiles[:]: + tile_x = tile.tile_x + tile_y = tile.tile_y + + if ( + tile_x < tile_x_first + or tile_x >= tile_x_last + or tile_y < tile_y_first + or tile_y >= tile_y_last + ): + tile.state = "done" + self.tile_map_set(tile_x, tile_y, False) + self._tiles.remove(tile) + self.canvas_map.remove(tile) + self.canvas_map.remove(tile.g_color) + else: + tile.size = (size, size) + tile.pos = (tile_x * size + self.delta_x, tile_y * size + self.delta_y) + + # Load new tiles if needed + x = tile_x_first + x_count // 2 - 1 + y = tile_y_first + y_count // 2 - 1 + arm_max = max(x_count, y_count) + 2 + arm_size = 1 + turn = 0 + while arm_size < arm_max: + for i in range(arm_size): + if ( + not self.tile_in_tile_map(x, y) + and y >= tile_y_first + and y < tile_y_last + and x >= tile_x_first + and x < tile_x_last + ): + self.load_tile(x, y, size, zoom) + + x += dirs[turn % 4 + 1] + y += dirs[turn % 4] + + if turn % 2 == 1: + arm_size += 1 + + turn += 1 + + def load_tile(self, x, y, size, zoom): + if self.tile_in_tile_map(x, y) or zoom != self._zoom: + return + self.load_tile_for_source(self.map_source, 1.0, size, x, y, zoom) + # XXX do overlay support + self.tile_map_set(x, y, True) + + def load_tile_for_source(self, map_source, opacity, size, x, y, zoom): + tile = Tile(size=(size, size), cache_dir=self.cache_dir) + tile.g_color = Color(1, 1, 1, 0) + tile.tile_x = x + tile.tile_y = y + tile.zoom = zoom + tile.pos = (x * size + self.delta_x, y * size + self.delta_y) + tile.map_source = map_source + tile.state = "loading" + if not self._pause: + map_source.fill_tile(tile) + self.canvas_map.add(tile.g_color) + self.canvas_map.add(tile) + self._tiles.append(tile) + + def move_tiles_to_background(self): + # remove all the tiles of the main map to the background map + # retain only the one who are on the current zoom level + # for all the tile in the background, stop the download if not yet started. + zoom = self._zoom + tiles = self._tiles + btiles = self._tiles_bg + canvas_map = self.canvas_map + tile_size = self.map_source.tile_size + + # move all tiles to background + while tiles: + tile = tiles.pop() + if tile.state == "loading": + tile.state = "done" + continue + btiles.append(tile) + + # clear the canvas + canvas_map.clear() + canvas_map.before.clear() + self._tilemap = {} + + # unsure if it's really needed, i personnally didn't get issues right now + # btiles.sort(key=lambda z: -z.zoom) + + # add all the btiles into the back canvas. + # except for the tiles that are owned by the current zoom level + for tile in btiles[:]: + if tile.zoom == zoom: + btiles.remove(tile) + tiles.append(tile) + tile.size = tile_size, tile_size + canvas_map.add(tile.g_color) + canvas_map.add(tile) + self.tile_map_set(tile.tile_x, tile.tile_y, True) + continue + canvas_map.before.add(tile.g_color) + canvas_map.before.add(tile) + + def remove_all_tiles(self): + # clear the map of all tiles. + self.canvas_map.clear() + self.canvas_map.before.clear() + for tile in self._tiles: + tile.state = "done" + del self._tiles[:] + del self._tiles_bg[:] + self._tilemap = {} + + def tile_map_set(self, tile_x, tile_y, value): + key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x + if value: + self._tilemap[key] = value + else: + self._tilemap.pop(key, None) + + def tile_in_tile_map(self, tile_x, tile_y): + key = tile_y * self.map_source.get_col_count(self._zoom) + tile_x + return key in self._tilemap + + def on_size(self, instance, size): + for layer in self._layers: + layer.size = size + self.center_on(self.lat, self.lon) + self.trigger_update(True) + + def on_pos(self, instance, pos): + self.center_on(self.lat, self.lon) + self.trigger_update(True) + + def on_map_source(self, instance, source): + if isinstance(source, string_types): + self.map_source = MapSource.from_provider(source) + elif isinstance(source, (tuple, list)): + cache_key, min_zoom, max_zoom, url, attribution, options = source + self.map_source = MapSource( + url=url, + cache_key=cache_key, + min_zoom=min_zoom, + max_zoom=max_zoom, + attribution=attribution, + cache_dir=self.cache_dir, + **options + ) + elif isinstance(source, MapSource): + self.map_source = source + else: + raise Exception("Invalid map source provider") + self.zoom = clamp(self.zoom, self.map_source.min_zoom, self.map_source.max_zoom) + self.remove_all_tiles() + self.trigger_update(True) diff --git a/kivyblocks/register.py b/kivyblocks/register.py index bc159b8..d04f1af 100644 --- a/kivyblocks/register.py +++ b/kivyblocks/register.py @@ -16,11 +16,25 @@ from .qrdata import QRCodeWidget # from .kivycamera import KivyCamera from .filebrowser import FileLoaderBrowser from .osc_server import OSCServer -from graph import Graph, MeshLinePlot, MeshStemPlot, LinePlot, \ +from .graph import Graph, MeshLinePlot, MeshStemPlot, LinePlot, \ SmoothLinePlot, ContourPlot, BarPlot, HBar, VBar, ScatterPlot, \ PointPlot +from .mapview import MapView r = Factory.register +r('Popup', Popup) +r('Graph', Graph) +r('MeshLinePlot', MeshLinePlot) +r('MeshStemPlot', MeshStemPlot) +r('LinePlot', LinePlot) +r('SmoothLinePlot', SmoothLinePlot) +r('ContourPlot', ContourPlot) +r('BarPlot', BarPlot) +r('HBar', HBar) +r('VBar', VBar) +r('ScatterPlot', ScatterPlot) +r('PointPlot', PointPlot) +r('MapView', MapView) r('OSCServer',OSCServer) r('DataGrid',DataGrid) r('FileLoaderBrowser',FileLoaderBrowser) diff --git a/kivyblocks/responsivelayout.py b/kivyblocks/responsivelayout.py index d01bffe..d17c547 100644 --- a/kivyblocks/responsivelayout.py +++ b/kivyblocks/responsivelayout.py @@ -14,23 +14,14 @@ class VResponsiveLayout(ScrollView): self.box_cols = cols super(VResponsiveLayout, self).__init__(**kw) self.options = kw - print('VResponsiveLayout():cols=',self.org_cols,'box_width=',self.box_width) self._inner = GridLayout(cols=self.org_cols, padding=2, spacing=2,size_hint=(1,None)) super(VResponsiveLayout,self).add_widget(self._inner) self._inner.bind( minimum_height=self._inner.setter('height')) self.sizeChangedTask = None - self.bind(pos=self.sizeChanged,size=self.sizeChanged) + self.bind(pos=self.setCols,size=self.setCols) - def sizeChanged(self,o,v=None): - if self.sizeChangedTask: - self.sizeChangedTask.cancel() - self.sizeChangedTask = Clock.schedule_once(self.sizeChangedWork,0.1) - - def sizeChangedWork(self,t=None): - self.setCols() - def on_orientation(self,o): self.setCols() @@ -49,19 +40,14 @@ class VResponsiveLayout(ScrollView): a = self._inner.remove_widget(widget,**kw) return a - def setCols(self,t=None): - self.box_width = self.org_box_width - if self.width < self.box_width: - self.cols = self.org_cols - else: - self.cols = int(self.width / self.box_width) - print('VResponsiveLayout()::setCols():self.cols=',self.cols, 'self.box_width=',self.box_width,'self.org_cols=',self.org_cols) - if isHandHold(): - w,h = self.size - if w < h: - self.box_width = w / self.org_cols - 2 - self.cols = self.org_cols - self._inner.cols = self.cols + def setCols(self,*args): + cols = round(self.width / self.org_box_width) + if cols < 1: + return + if isHandHold() and self.width < self.height: + cols = self.org_cols + box_width = self.width / cols - 2 + self._inner.cols = cols for w in self._inner.children: - w.width = self.box_width + w.width = box_width diff --git a/kivyblocks/toggleitems.py b/kivyblocks/toggleitems.py index e21e193..b376e69 100644 --- a/kivyblocks/toggleitems.py +++ b/kivyblocks/toggleitems.py @@ -36,6 +36,17 @@ class PressableBox(TouchRippleButtonBehavior, Box): def getValue(self): return self.user_data +""" +ToggleItems format: +{ + color_level: + radius: + unit_size: + items_desc: + border_width: + orientation: +} +""" class ToggleItems(BGColorBehavior, BoxLayout): def __init__(self, color_level=1, @@ -112,6 +123,6 @@ class ToggleItems(BGColorBehavior, BoxLayout): for i in self.item_widgets: if i.getValue() == v: i.selected() - self.select_iten(i) + self.select_item(i) return diff --git a/kivyblocks/toolbar.py b/kivyblocks/toolbar.py index 1dee482..8b7f6b8 100644 --- a/kivyblocks/toolbar.py +++ b/kivyblocks/toolbar.py @@ -16,84 +16,16 @@ from .ready import WidgetReady from .color_definitions import getColors from .bgcolorbehavior import BGColorBehavior from .baseWidget import Text - -""" -toobar={ - "mode":"icon", "icontext","text" - img_size=2, - text_size=1, - "tools":[ - ] -} - -tool options -{ - name:'', - label:'' - img='' -} -""" -class Tool(ButtonBehavior, BGColorBehavior, BoxLayout): - def __init__(self,ancestor=None,**opts): - if ancestor is None: - ancestor = App.get_running_app().root - self.widget_id = opts['name'] - ButtonBehavior.__init__(self) - BoxLayout.__init__(self, - size_hint_y=None) - BGColorBehavior.__init__(self,color_level=ancestor.color_level, - radius=ancestor.radius) - self.bl = BoxLayout(orientation='vertical', - size_hint_y=None) - self.add_widget(self.bl) - self.opts = DictObject(**opts) - if not self.opts.img_size: - self.opts.img_size = 2 - if not self.opts.text_size: - self.opts.text_size = 1 - app = App.get_running_app() - size = 0 - if self.opts.img_src: - size = CSize(self.opts.img_size or 2) - img = AsyncImage(source=self.opts.img_src, - size_hint=(None,None), - size=(size,size)) - self.bl.add_widget(img) - - tsize = CSize(self.opts.text_size) - label = self.opts.label or self.opts.name - self.lbl = Text(i18n=True, - text=label, - font_size=int(tsize), - text_size=(CSize(len(label)), tsize), - height=tsize, - width=CSize(len(label)), - size_hint=(None,None), - ) - self.bl.add_widget(self.lbl) - self.height = (size + tsize)*1.1 - - def on_size(self,obj,size): - Logger.info('toolbar: Tool() on_size fired') - #self.lbl.color, self.bgcolor = getColors(self.ancestor.color_level, - # selected=False) - #self.lbl.bgcolor = self.bgcolor - - def on_press(self): - print('Tool(). pressed ...............') - - def setActive(self,flag): - if flag: - self.selected() - else: - self.unselected() - +from .toggleitems import PressableBox, ToggleItems """ toolbar options { + color_level: + radius: + "mode":"icon", "icontext","text" img_size=1.5, - text_size=0.7, + text_size=0.5, tools:[ { "name":"myid", @@ -104,44 +36,73 @@ toolbar options ] } """ -class Toolbar(BGColorBehavior, GridLayout): - def __init__(self, ancestor=None,**opts): - self.opts = DictObject(**opts) +class Toolbar(BoxLayout): + def __init__(self, color_level=-1, + radius=[], + img_size=1.5, + text_size=0.5, + tools=[], **opts): + self.color_level = color_level + self.radius = radius + self.img_size = img_size + self.text_size = text_size + self.tools = tools self.tool_widgets={} - GridLayout.__init__(self,cols = len(self.opts.tools)) - color_level = 0 - if isinstance(ancestor, BGColorBehavior): - color_level = ancestor.color_level + 1 - BGColorBehavior.__init__(self,color_level=color_level) + BoxLayout.__init__(self, **opts) + self.register_event_type('on_press') self.size_hint = (1,None) first = True - for opt in self.opts.tools: - opt.img_size = self.opts.img_size - opt.text_size = self.opts.text_size - if opt.img_src: - purl = None - if ancestor and hasattr(ancestor, 'parenturl'): - purl = ancestor.parenturl - opt.img_src = absurl(opt.img_src,purl) - tool = Tool(ancestor=ancestor, **opt) - if first: - first = False - h = ancestor - if not ancestor: - h = App.get_runnung_app().root - self.tool_widgets[opt.name] = tool - box = BoxLayout() - box.add_widget(tool) - self.add_widget(box) - tool.bind(on_press=self.tool_press) - self.height = tool.height * 1.1 + subs_desc = [] + for opt in self.tools: + subwidgets = [] + img_src = opt.get('img_src',None) + if img_src: + subwidgets.append({ + "widgettype":"AsyncImage", + "options":{ + "size_hint_y":None, + "height":CSize(self.img_size), + "source":img_src + } + }) + text = opt.get('label', None) + if text: + subwidgets.append({ + "widgettype":"Text", + "options":{ + "size_hint_y":None, + "i18n":True, + "height":CSize(self.text_size), + "font_size":CSize(self.text_size), + "text":text + } + }) + desc = { + "widgettype":"VBox", + "options":{ + }, + "subwidgets":subwidgets, + "data":opt.get('name') + } + subs_desc.append(desc) + + self.toggle_items = ToggleItems( + color_level=self.color_level, + radius=self.radius, + unit_size=self.img_size + self.text_size, + items_desc=subs_desc) + for ti in self.toggle_items.children: + ti.widget_id = ti.user_data + self.height = CSize(self.img_size + self.text_size) + 10 + self.size_hint_y = None + self.toggle_items.bind(on_press=self.tool_press) + self.add_widget(self.toggle_items) + + def on_press(self, o): + print('on_press(),', o) def tool_press(self,o,v=None): - for n,w in self.tool_widgets.items(): - active = False - if w == o: - active = True - w.setActive(active) + self.dispatch('on_press',self.toggle_items.getValue()) """ Toolpage options @@ -162,53 +123,63 @@ Toolpage options """ class ToolPage(BGColorBehavior, BoxLayout): - def __init__(self,color_level=-1,radius=[],**opts): + def __init__(self,color_level=-1,radius=[],tool_at='top', **opts): self.opts = DictObject(**opts) - if self.opts.tool_at in [ 'top','bottom']: + if tool_at in [ 'top','bottom']: orient = 'vertical' else: orient = 'horizontal' self.color_level=self.opts.color_level or 0 - self.radius = self.opts.radius + self.sub_radius = self.opts.radius + self.tool_at = tool_at BoxLayout.__init__(self,orientation=orient) BGColorBehavior.__init__(self, color_level=color_level, - radius=radius) + radius=[]) self.content = None self.toolbar = None self.init() - self.show_firstpage() + # self.show_firstpage() def on_size(self,obj,size): if self.content is None: return - x,y = size - self.toolbar.width = x - self.content.width = x - self.content.height = y - self.toolbar.height + if self.tool_at in ['top','bottom']: + self.toolbar.width = self.width + self.content.width = self.width + self.content.height = self.height - self.toolbar.height + else: + self.toolbar.height = self.height + self.content.height = self.height + self.content.width = self.width - self.toolbar.width def showPage(self,obj): self._show_page(obj.opts) def show_firstpage(self,t=None): - return d = self.children[0] - d.dispatch('on_press') + d.dispatch('on_press', d) def init(self): self.initFlag = True self.mywidgets = {} self.content = BoxLayout() self.content.widget_id = 'content' - for t in self.opts.tools: - parenturl = None - if hasattr(self,'parenturl'): - parenturl = self.parenturl - t.img_src = absurl(t.img_src,parenturl) - - opts = self.opts - self.toolbar = Toolbar(ancestor=self, **self.opts) - if self.opts.tool_at in ['top','left']: + opts = self.opts.copy() + if self.tool_at in ['top','bottom']: + opts['size_hint_x'] = 1 + opts['size_hint_y'] = None + opts['height'] = CSize(self.opts.img_size + \ + self.opts.text_size) + 10 + else: + opts['size_hint_y'] = 1 + opts['size_hint_x'] = None + opts['width'] = CSize(self.opts.img_size + \ + self.opts.text_size) + 10 + self.toolbar = Toolbar(color_level=self.color_level, + radius=self.sub_radius, + **opts) + if self.tool_at in ['top','left']: self.add_widget(self.toolbar) self.add_widget(self.content) else: