bugfix
This commit is contained in:
parent
26f7c6c47d
commit
5e0d1c60af
@ -10,6 +10,7 @@ from kivy.uix.label import Label
|
|||||||
from kivy.event import EventDispatcher
|
from kivy.event import EventDispatcher
|
||||||
from kivy.metrics import dp
|
from kivy.metrics import dp
|
||||||
from kivy.core.window import Window
|
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 ActionBar,ActionView,ActionPrevious,ActionItem,ActionButton
|
||||||
from kivy.uix.actionbar import ActionToggleButton, ActionCheck,ActionSeparator,ActionGroup
|
from kivy.uix.actionbar import ActionToggleButton, ActionCheck,ActionSeparator,ActionGroup
|
||||||
@ -213,7 +214,6 @@ class Title6(Text):
|
|||||||
|
|
||||||
class Modal(BGColorBehavior, ModalView):
|
class Modal(BGColorBehavior, ModalView):
|
||||||
def __init__(self, auto_open=False, color_level=-1, radius=[], **kw):
|
def __init__(self, auto_open=False, color_level=-1, radius=[], **kw):
|
||||||
kw.update({'auto_dismiss':False})
|
|
||||||
ModalView.__init__(self, **kw)
|
ModalView.__init__(self, **kw)
|
||||||
BGColorBehavior.__init__(self, color_level=color_level,
|
BGColorBehavior.__init__(self, color_level=color_level,
|
||||||
radius=radius)
|
radius=radius)
|
||||||
|
@ -48,6 +48,9 @@ class BGColorBehavior(object):
|
|||||||
else:
|
else:
|
||||||
print('on_bgcolor():self.canvas is None')
|
print('on_bgcolor():self.canvas is None')
|
||||||
|
|
||||||
|
def is_selected(self):
|
||||||
|
return self.bgcolor == self.selected_bgcolor
|
||||||
|
|
||||||
def selected(self):
|
def selected(self):
|
||||||
if self.bgcolor == self.selected_bgcolor:
|
if self.bgcolor == self.selected_bgcolor:
|
||||||
return
|
return
|
||||||
|
@ -192,7 +192,10 @@ class Blocks(EventDispatcher):
|
|||||||
errback=None,**kw):
|
errback=None,**kw):
|
||||||
|
|
||||||
if url is None:
|
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://'):
|
if url.startswith('file://'):
|
||||||
filename = url[7:]
|
filename = url[7:]
|
||||||
@ -201,19 +204,14 @@ class Blocks(EventDispatcher):
|
|||||||
dic = json.loads(b)
|
dic = json.loads(b)
|
||||||
return dic
|
return dic
|
||||||
elif url.startswith('http://') or url.startswith('https://'):
|
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:
|
try:
|
||||||
hc = HttpClient()
|
hc = HttpClient()
|
||||||
resp=hc(url,method=method,params=params,files=files)
|
resp=hc(url,method=method,params=params,files=files)
|
||||||
return resp
|
return resp
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errback(None,e)
|
if errback:
|
||||||
|
return errback(None,e)
|
||||||
|
return None
|
||||||
else:
|
else:
|
||||||
config = getConfig()
|
config = getConfig()
|
||||||
url = config.uihome + url
|
url = config.uihome + url
|
||||||
@ -312,7 +310,7 @@ class Blocks(EventDispatcher):
|
|||||||
if isinstance(v,dict) and v.get('widgettype'):
|
if isinstance(v,dict) and v.get('widgettype'):
|
||||||
b = Blocks()
|
b = Blocks()
|
||||||
w = b.w_build(v)
|
w = b.w_build(v)
|
||||||
if hasattr(widgets,k):
|
if hasattr(widget,k):
|
||||||
aw = getattr(widget,k)
|
aw = getattr(widget,k)
|
||||||
if isinstance(aw,Layout):
|
if isinstance(aw,Layout):
|
||||||
aw.add_widget(w)
|
aw.add_widget(w)
|
||||||
@ -370,7 +368,7 @@ class Blocks(EventDispatcher):
|
|||||||
return
|
return
|
||||||
f = self.buildAction(widget,desc)
|
f = self.buildAction(widget,desc)
|
||||||
if f is None:
|
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
|
return
|
||||||
w.bind(**{event:f})
|
w.bind(**{event:f})
|
||||||
|
|
||||||
@ -392,8 +390,9 @@ class Blocks(EventDispatcher):
|
|||||||
def blocksAction(self,widget,desc, *args):
|
def blocksAction(self,widget,desc, *args):
|
||||||
target = Blocks.getWidgetById(desc.get('target','self'),widget)
|
target = Blocks.getWidgetById(desc.get('target','self'),widget)
|
||||||
if target is None:
|
if target is None:
|
||||||
Logger.Info('Block: blocksAction():desc(%s) target not found',
|
Logger.info('Block: blocksAction():desc(%s) target not found',
|
||||||
str(desc))
|
str(desc))
|
||||||
|
return
|
||||||
add_mode = desc.get('mode','replace')
|
add_mode = desc.get('mode','replace')
|
||||||
opts = desc.get('options').copy()
|
opts = desc.get('options').copy()
|
||||||
d = self.getActionData(widget,desc)
|
d = self.getActionData(widget,desc)
|
||||||
@ -418,8 +417,9 @@ class Blocks(EventDispatcher):
|
|||||||
def urlwidgetAction(self,widget,desc, *args):
|
def urlwidgetAction(self,widget,desc, *args):
|
||||||
target = Blocks.getWidgetById(desc.get('target','self'),widget)
|
target = Blocks.getWidgetById(desc.get('target','self'),widget)
|
||||||
if target is None:
|
if target is None:
|
||||||
Logger.Info('Block: urlwidgetAction():desc(%s) target not found',
|
Logger.info('Block: urlwidgetAction():desc(%s) target not found',
|
||||||
str(desc))
|
str(desc))
|
||||||
|
return
|
||||||
add_mode = desc.get('mode','replace')
|
add_mode = desc.get('mode','replace')
|
||||||
opts = desc.get('options').copy()
|
opts = desc.get('options').copy()
|
||||||
p = opts.get('params',{}).copy()
|
p = opts.get('params',{}).copy()
|
||||||
@ -470,13 +470,14 @@ class Blocks(EventDispatcher):
|
|||||||
target = Blocks.getWidgetById(desc.get('target','self'),
|
target = Blocks.getWidgetById(desc.get('target','self'),
|
||||||
from_widget=widget)
|
from_widget=widget)
|
||||||
if target is None:
|
if target is None:
|
||||||
Logger.Info('Block: registedfunctionAction():desc(%s) target not found',
|
Logger.info('Block: registedfunctionAction():desc(%s) target not found',
|
||||||
str(desc))
|
str(desc))
|
||||||
|
return
|
||||||
rf = RegisterFunction()
|
rf = RegisterFunction()
|
||||||
name = desc.get('rfname')
|
name = desc.get('rfname')
|
||||||
func = rf.get(name)
|
func = rf.get(name)
|
||||||
if func is None:
|
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)
|
str(desc), name)
|
||||||
raise Exception('rfname(%s) not found' % name)
|
raise Exception('rfname(%s) not found' % name)
|
||||||
|
|
||||||
@ -488,13 +489,13 @@ class Blocks(EventDispatcher):
|
|||||||
def scriptAction(self, widget, desc, *args):
|
def scriptAction(self, widget, desc, *args):
|
||||||
script = desc.get('script')
|
script = desc.get('script')
|
||||||
if not script:
|
if not script:
|
||||||
Logger.Info('Block: scriptAction():desc(%s) target not found',
|
Logger.info('Block: scriptAction():desc(%s) target not found',
|
||||||
str(desc))
|
str(desc))
|
||||||
return
|
return
|
||||||
target = Blocks.getWidgetById(desc.get('target','self'),
|
target = Blocks.getWidgetById(desc.get('target','self'),
|
||||||
from_widget=widget)
|
from_widget=widget)
|
||||||
if target is None:
|
if target is None:
|
||||||
Logger.Info('Block: scriptAction():desc(%s) target not found',
|
Logger.info('Block: scriptAction():desc(%s) target not found',
|
||||||
str(desc))
|
str(desc))
|
||||||
d = self.getActionData(widget,desc)
|
d = self.getActionData(widget,desc)
|
||||||
ns = {
|
ns = {
|
||||||
@ -512,8 +513,9 @@ class Blocks(EventDispatcher):
|
|||||||
method = desc.get('method')
|
method = desc.get('method')
|
||||||
target = Blocks.getWidgetById(desc.get('target','self'),widget)
|
target = Blocks.getWidgetById(desc.get('target','self'),widget)
|
||||||
if target is None:
|
if target is None:
|
||||||
Logger.Info('Block: methodAction():desc(%s) target not found',
|
Logger.info('Block: methodAction():desc(%s) target not found',
|
||||||
str(desc))
|
str(desc))
|
||||||
|
return
|
||||||
if hasattr(target,method):
|
if hasattr(target,method):
|
||||||
f = getattr(target, method)
|
f = getattr(target, method)
|
||||||
kwargs = desc.get('options',{}).copy()
|
kwargs = desc.get('options',{}).copy()
|
||||||
@ -536,8 +538,6 @@ class Blocks(EventDispatcher):
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
name = desc['widgettype']
|
|
||||||
|
|
||||||
def doit(desc):
|
def doit(desc):
|
||||||
if not isinstance(desc,dict):
|
if not isinstance(desc,dict):
|
||||||
Logger.info('Block: desc must be a dict object',
|
Logger.info('Block: desc must be a dict object',
|
||||||
@ -550,6 +550,12 @@ class Blocks(EventDispatcher):
|
|||||||
if hasattr(widget,'ready'):
|
if hasattr(widget,'ready'):
|
||||||
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":
|
while desc['widgettype'] == "urlwidget":
|
||||||
opts = desc.get('options',{}).copy()
|
opts = desc.get('options',{}).copy()
|
||||||
extend = desc.get('extend')
|
extend = desc.get('extend')
|
||||||
@ -564,6 +570,13 @@ class Blocks(EventDispatcher):
|
|||||||
if opts.get('url'):
|
if opts.get('url'):
|
||||||
del opts['url']
|
del opts['url']
|
||||||
desc = self.getUrlData(url,**opts)
|
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:
|
if addon:
|
||||||
desc = dictExtend(desc,addon)
|
desc = dictExtend(desc,addon)
|
||||||
doit(desc)
|
doit(desc)
|
||||||
|
@ -75,7 +75,7 @@ class BoxViewer(WidgetReady, BoxLayout):
|
|||||||
self.dataloader.bind(on_submit=self.getParams)
|
self.dataloader.bind(on_submit=self.getParams)
|
||||||
self.add_widget(self.viewContainer)
|
self.add_widget(self.viewContainer)
|
||||||
self.register_event_type('on_selected')
|
self.register_event_type('on_selected')
|
||||||
self.viewContainer.bind(size=self.resetCols,
|
self.bind(size=self.resetCols,
|
||||||
pos=self.resetCols)
|
pos=self.resetCols)
|
||||||
self.viewContainer.bind(on_scroll_stop=self.on_scroll_stop)
|
self.viewContainer.bind(on_scroll_stop=self.on_scroll_stop)
|
||||||
# use_keyboard() will block the python
|
# use_keyboard() will block the python
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
27
kivyblocks/mapview/__init__.py
Normal file
27
kivyblocks/mapview/__init__.py
Normal file
@ -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",
|
||||||
|
]
|
1
kivyblocks/mapview/_version.py
Normal file
1
kivyblocks/mapview/_version.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "1.0.5"
|
449
kivyblocks/mapview/clustered_marker_layer.py
Normal file
449
kivyblocks/mapview/clustered_marker_layer.py
Normal file
@ -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(
|
||||||
|
"""
|
||||||
|
<ClusterMapMarker>:
|
||||||
|
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 "<Marker lon={} lat={} source={}>".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)
|
5
kivyblocks/mapview/constants.py
Normal file
5
kivyblocks/mapview/constants.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
MIN_LATITUDE = -90.0
|
||||||
|
MAX_LATITUDE = 90.0
|
||||||
|
MIN_LONGITUDE = -180.0
|
||||||
|
MAX_LONGITUDE = 180.0
|
||||||
|
CACHE_DIR = "cache"
|
123
kivyblocks/mapview/downloader.py
Normal file
123
kivyblocks/mapview/downloader.py
Normal file
@ -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
|
381
kivyblocks/mapview/geojson.py
Normal file
381
kivyblocks/mapview/geojson.py
Normal file
@ -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
|
BIN
kivyblocks/mapview/icons/cluster.png
Normal file
BIN
kivyblocks/mapview/icons/cluster.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 600 B |
BIN
kivyblocks/mapview/icons/marker.png
Normal file
BIN
kivyblocks/mapview/icons/marker.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.1 KiB |
121
kivyblocks/mapview/mbtsource.py
Normal file
121
kivyblocks/mapview/mbtsource.py
Normal file
@ -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)
|
212
kivyblocks/mapview/source.py
Normal file
212
kivyblocks/mapview/source.py
Normal file
@ -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)
|
29
kivyblocks/mapview/types.py
Normal file
29
kivyblocks/mapview/types.py
Normal file
@ -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
|
50
kivyblocks/mapview/utils.py
Normal file
50
kivyblocks/mapview/utils.py
Normal file
@ -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
|
999
kivyblocks/mapview/view.py
Normal file
999
kivyblocks/mapview/view.py
Normal file
@ -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(
|
||||||
|
"""
|
||||||
|
<MapMarker>:
|
||||||
|
size_hint: None, None
|
||||||
|
source: root.source
|
||||||
|
size: list(map(dp, self.texture_size))
|
||||||
|
allow_stretch: True
|
||||||
|
|
||||||
|
<MapView>:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
<MapViewScatter>:
|
||||||
|
auto_bring_to_front: False
|
||||||
|
do_rotation: False
|
||||||
|
scale_min: 0.2
|
||||||
|
scale_max: 3.
|
||||||
|
|
||||||
|
<MapMarkerPopup>:
|
||||||
|
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)
|
@ -16,11 +16,25 @@ from .qrdata import QRCodeWidget
|
|||||||
# from .kivycamera import KivyCamera
|
# from .kivycamera import KivyCamera
|
||||||
from .filebrowser import FileLoaderBrowser
|
from .filebrowser import FileLoaderBrowser
|
||||||
from .osc_server import OSCServer
|
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, \
|
SmoothLinePlot, ContourPlot, BarPlot, HBar, VBar, ScatterPlot, \
|
||||||
PointPlot
|
PointPlot
|
||||||
|
from .mapview import MapView
|
||||||
|
|
||||||
r = Factory.register
|
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('OSCServer',OSCServer)
|
||||||
r('DataGrid',DataGrid)
|
r('DataGrid',DataGrid)
|
||||||
r('FileLoaderBrowser',FileLoaderBrowser)
|
r('FileLoaderBrowser',FileLoaderBrowser)
|
||||||
|
@ -14,23 +14,14 @@ class VResponsiveLayout(ScrollView):
|
|||||||
self.box_cols = cols
|
self.box_cols = cols
|
||||||
super(VResponsiveLayout, self).__init__(**kw)
|
super(VResponsiveLayout, self).__init__(**kw)
|
||||||
self.options = kw
|
self.options = kw
|
||||||
print('VResponsiveLayout():cols=',self.org_cols,'box_width=',self.box_width)
|
|
||||||
self._inner = GridLayout(cols=self.org_cols, padding=2,
|
self._inner = GridLayout(cols=self.org_cols, padding=2,
|
||||||
spacing=2,size_hint=(1,None))
|
spacing=2,size_hint=(1,None))
|
||||||
super(VResponsiveLayout,self).add_widget(self._inner)
|
super(VResponsiveLayout,self).add_widget(self._inner)
|
||||||
self._inner.bind(
|
self._inner.bind(
|
||||||
minimum_height=self._inner.setter('height'))
|
minimum_height=self._inner.setter('height'))
|
||||||
self.sizeChangedTask = None
|
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):
|
def on_orientation(self,o):
|
||||||
self.setCols()
|
self.setCols()
|
||||||
|
|
||||||
@ -49,19 +40,14 @@ class VResponsiveLayout(ScrollView):
|
|||||||
a = self._inner.remove_widget(widget,**kw)
|
a = self._inner.remove_widget(widget,**kw)
|
||||||
return a
|
return a
|
||||||
|
|
||||||
def setCols(self,t=None):
|
def setCols(self,*args):
|
||||||
self.box_width = self.org_box_width
|
cols = round(self.width / self.org_box_width)
|
||||||
if self.width < self.box_width:
|
if cols < 1:
|
||||||
self.cols = self.org_cols
|
return
|
||||||
else:
|
if isHandHold() and self.width < self.height:
|
||||||
self.cols = int(self.width / self.box_width)
|
cols = self.org_cols
|
||||||
print('VResponsiveLayout()::setCols():self.cols=',self.cols, 'self.box_width=',self.box_width,'self.org_cols=',self.org_cols)
|
box_width = self.width / cols - 2
|
||||||
if isHandHold():
|
self._inner.cols = cols
|
||||||
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
|
|
||||||
for w in self._inner.children:
|
for w in self._inner.children:
|
||||||
w.width = self.box_width
|
w.width = box_width
|
||||||
|
|
||||||
|
@ -36,6 +36,17 @@ class PressableBox(TouchRippleButtonBehavior, Box):
|
|||||||
def getValue(self):
|
def getValue(self):
|
||||||
return self.user_data
|
return self.user_data
|
||||||
|
|
||||||
|
"""
|
||||||
|
ToggleItems format:
|
||||||
|
{
|
||||||
|
color_level:
|
||||||
|
radius:
|
||||||
|
unit_size:
|
||||||
|
items_desc:
|
||||||
|
border_width:
|
||||||
|
orientation:
|
||||||
|
}
|
||||||
|
"""
|
||||||
class ToggleItems(BGColorBehavior, BoxLayout):
|
class ToggleItems(BGColorBehavior, BoxLayout):
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
color_level=1,
|
color_level=1,
|
||||||
@ -112,6 +123,6 @@ class ToggleItems(BGColorBehavior, BoxLayout):
|
|||||||
for i in self.item_widgets:
|
for i in self.item_widgets:
|
||||||
if i.getValue() == v:
|
if i.getValue() == v:
|
||||||
i.selected()
|
i.selected()
|
||||||
self.select_iten(i)
|
self.select_item(i)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -16,84 +16,16 @@ from .ready import WidgetReady
|
|||||||
from .color_definitions import getColors
|
from .color_definitions import getColors
|
||||||
from .bgcolorbehavior import BGColorBehavior
|
from .bgcolorbehavior import BGColorBehavior
|
||||||
from .baseWidget import Text
|
from .baseWidget import Text
|
||||||
|
from .toggleitems import PressableBox, ToggleItems
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
toolbar options
|
toolbar options
|
||||||
{
|
{
|
||||||
|
color_level:
|
||||||
|
radius:
|
||||||
|
"mode":"icon", "icontext","text"
|
||||||
img_size=1.5,
|
img_size=1.5,
|
||||||
text_size=0.7,
|
text_size=0.5,
|
||||||
tools:[
|
tools:[
|
||||||
{
|
{
|
||||||
"name":"myid",
|
"name":"myid",
|
||||||
@ -104,44 +36,73 @@ toolbar options
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
class Toolbar(BGColorBehavior, GridLayout):
|
class Toolbar(BoxLayout):
|
||||||
def __init__(self, ancestor=None,**opts):
|
def __init__(self, color_level=-1,
|
||||||
self.opts = DictObject(**opts)
|
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={}
|
self.tool_widgets={}
|
||||||
GridLayout.__init__(self,cols = len(self.opts.tools))
|
BoxLayout.__init__(self, **opts)
|
||||||
color_level = 0
|
self.register_event_type('on_press')
|
||||||
if isinstance(ancestor, BGColorBehavior):
|
|
||||||
color_level = ancestor.color_level + 1
|
|
||||||
BGColorBehavior.__init__(self,color_level=color_level)
|
|
||||||
self.size_hint = (1,None)
|
self.size_hint = (1,None)
|
||||||
first = True
|
first = True
|
||||||
for opt in self.opts.tools:
|
subs_desc = []
|
||||||
opt.img_size = self.opts.img_size
|
for opt in self.tools:
|
||||||
opt.text_size = self.opts.text_size
|
subwidgets = []
|
||||||
if opt.img_src:
|
img_src = opt.get('img_src',None)
|
||||||
purl = None
|
if img_src:
|
||||||
if ancestor and hasattr(ancestor, 'parenturl'):
|
subwidgets.append({
|
||||||
purl = ancestor.parenturl
|
"widgettype":"AsyncImage",
|
||||||
opt.img_src = absurl(opt.img_src,purl)
|
"options":{
|
||||||
tool = Tool(ancestor=ancestor, **opt)
|
"size_hint_y":None,
|
||||||
if first:
|
"height":CSize(self.img_size),
|
||||||
first = False
|
"source":img_src
|
||||||
h = ancestor
|
}
|
||||||
if not ancestor:
|
})
|
||||||
h = App.get_runnung_app().root
|
text = opt.get('label', None)
|
||||||
self.tool_widgets[opt.name] = tool
|
if text:
|
||||||
box = BoxLayout()
|
subwidgets.append({
|
||||||
box.add_widget(tool)
|
"widgettype":"Text",
|
||||||
self.add_widget(box)
|
"options":{
|
||||||
tool.bind(on_press=self.tool_press)
|
"size_hint_y":None,
|
||||||
self.height = tool.height * 1.1
|
"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):
|
def tool_press(self,o,v=None):
|
||||||
for n,w in self.tool_widgets.items():
|
self.dispatch('on_press',self.toggle_items.getValue())
|
||||||
active = False
|
|
||||||
if w == o:
|
|
||||||
active = True
|
|
||||||
w.setActive(active)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Toolpage options
|
Toolpage options
|
||||||
@ -162,53 +123,63 @@ Toolpage options
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
class ToolPage(BGColorBehavior, BoxLayout):
|
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)
|
self.opts = DictObject(**opts)
|
||||||
if self.opts.tool_at in [ 'top','bottom']:
|
if tool_at in [ 'top','bottom']:
|
||||||
orient = 'vertical'
|
orient = 'vertical'
|
||||||
else:
|
else:
|
||||||
orient = 'horizontal'
|
orient = 'horizontal'
|
||||||
self.color_level=self.opts.color_level or 0
|
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)
|
BoxLayout.__init__(self,orientation=orient)
|
||||||
BGColorBehavior.__init__(self,
|
BGColorBehavior.__init__(self,
|
||||||
color_level=color_level,
|
color_level=color_level,
|
||||||
radius=radius)
|
radius=[])
|
||||||
self.content = None
|
self.content = None
|
||||||
self.toolbar = None
|
self.toolbar = None
|
||||||
self.init()
|
self.init()
|
||||||
self.show_firstpage()
|
# self.show_firstpage()
|
||||||
|
|
||||||
def on_size(self,obj,size):
|
def on_size(self,obj,size):
|
||||||
if self.content is None:
|
if self.content is None:
|
||||||
return
|
return
|
||||||
x,y = size
|
if self.tool_at in ['top','bottom']:
|
||||||
self.toolbar.width = x
|
self.toolbar.width = self.width
|
||||||
self.content.width = x
|
self.content.width = self.width
|
||||||
self.content.height = y - self.toolbar.height
|
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):
|
def showPage(self,obj):
|
||||||
self._show_page(obj.opts)
|
self._show_page(obj.opts)
|
||||||
|
|
||||||
def show_firstpage(self,t=None):
|
def show_firstpage(self,t=None):
|
||||||
return
|
|
||||||
d = self.children[0]
|
d = self.children[0]
|
||||||
d.dispatch('on_press')
|
d.dispatch('on_press', d)
|
||||||
|
|
||||||
def init(self):
|
def init(self):
|
||||||
self.initFlag = True
|
self.initFlag = True
|
||||||
self.mywidgets = {}
|
self.mywidgets = {}
|
||||||
self.content = BoxLayout()
|
self.content = BoxLayout()
|
||||||
self.content.widget_id = 'content'
|
self.content.widget_id = 'content'
|
||||||
for t in self.opts.tools:
|
opts = self.opts.copy()
|
||||||
parenturl = None
|
if self.tool_at in ['top','bottom']:
|
||||||
if hasattr(self,'parenturl'):
|
opts['size_hint_x'] = 1
|
||||||
parenturl = self.parenturl
|
opts['size_hint_y'] = None
|
||||||
t.img_src = absurl(t.img_src,parenturl)
|
opts['height'] = CSize(self.opts.img_size + \
|
||||||
|
self.opts.text_size) + 10
|
||||||
opts = self.opts
|
else:
|
||||||
self.toolbar = Toolbar(ancestor=self, **self.opts)
|
opts['size_hint_y'] = 1
|
||||||
if self.opts.tool_at in ['top','left']:
|
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.toolbar)
|
||||||
self.add_widget(self.content)
|
self.add_widget(self.content)
|
||||||
else:
|
else:
|
||||||
|
Loading…
Reference in New Issue
Block a user