1#!/usr/bin/env python
2
3
4#############################################################################
5##
6## Copyright (C) 2013 Riverbank Computing Limited
7## Copyright (C) 2010 Hans-Peter Jansen <hpj@urpla.net>.
8## Copyright (C) 2010 Nokia Corporation and/or its subsidiary(-ies).
9## All rights reserved.
10##
11## This file is part of the examples of PyQt.
12##
13## $QT_BEGIN_LICENSE:LGPL$
14## Commercial Usage
15## Licensees holding valid Qt Commercial licenses may use this file in
16## accordance with the Qt Commercial License Agreement provided with the
17## Software or, alternatively, in accordance with the terms contained in
18## a written agreement between you and Nokia.
19##
20## GNU Lesser General Public License Usage
21## Alternatively, this file may be used under the terms of the GNU Lesser
22## General Public License version 2.1 as published by the Free Software
23## Foundation and appearing in the file LICENSE.LGPL included in the
24## packaging of this file.  Please review the following information to
25## ensure the GNU Lesser General Public License version 2.1 requirements
26## will be met: http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html.
27##
28## In addition, as a special exception, Nokia gives you certain additional
29## rights.  These rights are described in the Nokia Qt LGPL Exception
30## version 1.1, included in the file LGPL_EXCEPTION.txt in this package.
31##
32## GNU General Public License Usage
33## Alternatively, this file may be used under the terms of the GNU
34## General Public License version 3.0 as published by the Free Software
35## Foundation and appearing in the file LICENSE.GPL included in the
36## packaging of this file.  Please review the following information to
37## ensure the GNU General Public License version 3.0 requirements will be
38## met: http://www.gnu.org/copyleft/gpl.html.
39##
40## If you have questions regarding the use of this file, please contact
41## Nokia at qt-info@nokia.com.
42## $QT_END_LICENSE$
43##
44#############################################################################
45
46
47import math
48
49from PyQt5.QtCore import (pyqtSignal, QBasicTimer, QObject, QPoint, QPointF,
50        QRect, QSize, QStandardPaths, Qt, QUrl)
51from PyQt5.QtGui import (QColor, QDesktopServices, QImage, QPainter,
52        QPainterPath, QPixmap, QRadialGradient)
53from PyQt5.QtWidgets import QAction, QApplication, QMainWindow, QWidget
54from PyQt5.QtNetwork import (QNetworkAccessManager, QNetworkDiskCache,
55        QNetworkRequest)
56
57
58# how long (milliseconds) the user need to hold (after a tap on the screen)
59# before triggering the magnifying glass feature
60# 701, a prime number, is the sum of 229, 233, 239
61# (all three are also prime numbers, consecutive!)
62HOLD_TIME = 701
63
64# maximum size of the magnifier
65# Hint: see above to find why I picked self one :)
66MAX_MAGNIFIER = 229
67
68# tile size in pixels
69TDIM = 256
70
71
72class Point(QPoint):
73    """QPoint, that is fully qualified as a dict key"""
74    def __init__(self, *par):
75        if par:
76            super(Point, self).__init__(*par)
77        else:
78            super(Point, self).__init__()
79
80    def __hash__(self):
81        return self.x() * 17 ^ self.y()
82
83    def __repr__(self):
84        return "Point(%s, %s)" % (self.x(), self.y())
85
86
87def tileForCoordinate(lat, lng, zoom):
88    zn = float(1 << zoom)
89    tx = float(lng + 180.0) / 360.0
90    ty = (1.0 - math.log(math.tan(lat * math.pi / 180.0) +
91          1.0 / math.cos(lat * math.pi / 180.0)) / math.pi) / 2.0
92
93    return QPointF(tx * zn, ty * zn)
94
95
96def longitudeFromTile(tx, zoom):
97    zn = float(1 << zoom)
98    lat = tx / zn * 360.0 - 180.0
99
100    return lat
101
102
103def latitudeFromTile(ty, zoom):
104    zn = float(1 << zoom)
105    n = math.pi - 2 * math.pi * ty / zn
106    lng = 180.0 / math.pi * math.atan(0.5 * (math.exp(n) - math.exp(-n)))
107
108    return lng
109
110
111class SlippyMap(QObject):
112
113    updated = pyqtSignal(QRect)
114
115    def __init__(self, parent=None):
116        super(SlippyMap, self).__init__(parent)
117
118        self._offset = QPoint()
119        self._tilesRect = QRect()
120        self._tilePixmaps = {} # Point(x, y) to QPixmap mapping
121        self._manager = QNetworkAccessManager()
122        self._url = QUrl()
123        # public vars
124        self.width = 400
125        self.height = 300
126        self.zoom = 15
127        self.latitude = 59.9138204
128        self.longitude = 10.7387413
129
130        self._emptyTile = QPixmap(TDIM, TDIM)
131        self._emptyTile.fill(Qt.lightGray)
132
133        cache = QNetworkDiskCache()
134        cache.setCacheDirectory(
135            QStandardPaths.writableLocation(QStandardPaths.CacheLocation))
136        self._manager.setCache(cache)
137        self._manager.finished.connect(self.handleNetworkData)
138
139    def invalidate(self):
140        if self.width <= 0 or self.height <= 0:
141            return
142
143        ct = tileForCoordinate(self.latitude, self.longitude, self.zoom)
144        tx = ct.x()
145        ty = ct.y()
146
147        # top-left corner of the center tile
148        xp = int(self.width / 2 - (tx - math.floor(tx)) * TDIM)
149        yp = int(self.height / 2 - (ty - math.floor(ty)) * TDIM)
150
151        # first tile vertical and horizontal
152        xa = (xp + TDIM - 1) / TDIM
153        ya = (yp + TDIM - 1) / TDIM
154        xs = int(tx) - xa
155        ys = int(ty) - ya
156
157        # offset for top-left tile
158        self._offset = QPoint(xp - xa * TDIM, yp - ya * TDIM)
159
160        # last tile vertical and horizontal
161        xe = int(tx) + (self.width - xp - 1) / TDIM
162        ye = int(ty) + (self.height - yp - 1) / TDIM
163
164        # build a rect
165        self._tilesRect = QRect(xs, ys, xe - xs + 1, ye - ys + 1)
166
167        if self._url.isEmpty():
168            self.download()
169
170        self.updated.emit(QRect(0, 0, self.width, self.height))
171
172    def render(self, p, rect):
173        for x in range(self._tilesRect.width()):
174            for y in range(self._tilesRect.height()):
175                tp = Point(x + self._tilesRect.left(), y + self._tilesRect.top())
176                box = self.tileRect(tp)
177                if rect.intersects(box):
178                    p.drawPixmap(box, self._tilePixmaps.get(tp, self._emptyTile))
179
180    def pan(self, delta):
181        dx = QPointF(delta) / float(TDIM)
182        center = tileForCoordinate(self.latitude, self.longitude, self.zoom) - dx
183        self.latitude = latitudeFromTile(center.y(), self.zoom)
184        self.longitude = longitudeFromTile(center.x(), self.zoom)
185        self.invalidate()
186
187    # slots
188    def handleNetworkData(self, reply):
189        img = QImage()
190        tp = Point(reply.request().attribute(QNetworkRequest.User))
191        url = reply.url()
192        if not reply.error():
193            if img.load(reply, None):
194                self._tilePixmaps[tp] = QPixmap.fromImage(img)
195        reply.deleteLater()
196        self.updated.emit(self.tileRect(tp))
197
198        # purge unused tiles
199        bound = self._tilesRect.adjusted(-2, -2, 2, 2)
200        for tp in list(self._tilePixmaps.keys()):
201            if not bound.contains(tp):
202                del self._tilePixmaps[tp]
203        self.download()
204
205    def download(self):
206        grab = None
207        for x in range(self._tilesRect.width()):
208            for y in range(self._tilesRect.height()):
209                tp = Point(self._tilesRect.topLeft() + QPoint(x, y))
210                if tp not in self._tilePixmaps:
211                    grab = QPoint(tp)
212                    break
213
214        if grab is None:
215            self._url = QUrl()
216            return
217
218        path = 'http://tile.openstreetmap.org/%d/%d/%d.png' % (self.zoom, grab.x(), grab.y())
219        self._url = QUrl(path)
220        request = QNetworkRequest()
221        request.setUrl(self._url)
222        request.setRawHeader(b'User-Agent', b'Nokia (PyQt) Graphics Dojo 1.0')
223        request.setAttribute(QNetworkRequest.User, grab)
224        self._manager.get(request)
225
226    def tileRect(self, tp):
227        t = tp - self._tilesRect.topLeft()
228        x = t.x() * TDIM + self._offset.x()
229        y = t.y() * TDIM + self._offset.y()
230
231        return QRect(x, y, TDIM, TDIM)
232
233
234class LightMaps(QWidget):
235    def __init__(self, parent = None):
236        super(LightMaps, self).__init__(parent)
237
238        self.pressed = False
239        self.snapped = False
240        self.zoomed = False
241        self.invert = False
242        self._normalMap = SlippyMap(self)
243        self._largeMap = SlippyMap(self)
244        self.pressPos = QPoint()
245        self.dragPos = QPoint()
246        self.tapTimer = QBasicTimer()
247        self.zoomPixmap = QPixmap()
248        self.maskPixmap = QPixmap()
249        self._normalMap.updated.connect(self.updateMap)
250        self._largeMap.updated.connect(self.update)
251
252    def setCenter(self, lat, lng):
253        self._normalMap.latitude = lat
254        self._normalMap.longitude = lng
255        self._normalMap.invalidate()
256        self._largeMap.invalidate()
257
258    # slots
259    def toggleNightMode(self):
260        self.invert = not self.invert
261        self.update()
262
263    def updateMap(self, r):
264        self.update(r)
265
266    def activateZoom(self):
267        self.zoomed = True
268        self.tapTimer.stop()
269        self._largeMap.zoom = self._normalMap.zoom + 1
270        self._largeMap.width = self._normalMap.width * 2
271        self._largeMap.height = self._normalMap.height * 2
272        self._largeMap.latitude = self._normalMap.latitude
273        self._largeMap.longitude = self._normalMap.longitude
274        self._largeMap.invalidate()
275        self.update()
276
277    def resizeEvent(self, event):
278        self._normalMap.width = self.width()
279        self._normalMap.height = self.height()
280        self._normalMap.invalidate()
281        self._largeMap.width = self._normalMap.width * 2
282        self._largeMap.height = self._normalMap.height * 2
283        self._largeMap.invalidate()
284
285    def paintEvent(self, event):
286        p = QPainter()
287        p.begin(self)
288        self._normalMap.render(p, event.rect())
289        p.setPen(Qt.black)
290        p.drawText(self.rect(), Qt.AlignBottom | Qt.TextWordWrap,
291                   "Map data CCBYSA 2009 OpenStreetMap.org contributors")
292        p.end()
293
294        if self.zoomed:
295            dim = min(self.width(), self.height())
296            magnifierSize = min(MAX_MAGNIFIER, dim * 2 / 3)
297            radius = magnifierSize / 2
298            ring = radius - 15
299            box = QSize(magnifierSize, magnifierSize)
300
301            # reupdate our mask
302            if self.maskPixmap.size() != box:
303                self.maskPixmap = QPixmap(box)
304                self.maskPixmap.fill(Qt.transparent)
305                g = QRadialGradient()
306                g.setCenter(radius, radius)
307                g.setFocalPoint(radius, radius)
308                g.setRadius(radius)
309                g.setColorAt(1.0, QColor(255, 255, 255, 0))
310                g.setColorAt(0.5, QColor(128, 128, 128, 255))
311                mask = QPainter(self.maskPixmap)
312                mask.setRenderHint(QPainter.Antialiasing)
313                mask.setCompositionMode(QPainter.CompositionMode_Source)
314                mask.setBrush(g)
315                mask.setPen(Qt.NoPen)
316                mask.drawRect(self.maskPixmap.rect())
317                mask.setBrush(QColor(Qt.transparent))
318                mask.drawEllipse(g.center(), ring, ring)
319                mask.end()
320
321            center = self.dragPos - QPoint(0, radius)
322            center += QPoint(0, radius / 2)
323            corner = center - QPoint(radius, radius)
324            xy = center * 2 - QPoint(radius, radius)
325            # only set the dimension to the magnified portion
326            if self.zoomPixmap.size() != box:
327                self.zoomPixmap = QPixmap(box)
328                self.zoomPixmap.fill(Qt.lightGray)
329
330            if True:
331                p = QPainter(self.zoomPixmap)
332                p.translate(-xy)
333                self._largeMap.render(p, QRect(xy, box))
334                p.end()
335
336            clipPath = QPainterPath()
337            clipPath.addEllipse(QPointF(center), ring, ring)
338            p = QPainter(self)
339            p.setRenderHint(QPainter.Antialiasing)
340            p.setClipPath(clipPath)
341            p.drawPixmap(corner, self.zoomPixmap)
342            p.setClipping(False)
343            p.drawPixmap(corner, self.maskPixmap)
344            p.setPen(Qt.gray)
345            p.drawPath(clipPath)
346
347        if self.invert:
348            p = QPainter(self)
349            p.setCompositionMode(QPainter.CompositionMode_Difference)
350            p.fillRect(event.rect(), Qt.white)
351            p.end()
352
353    def timerEvent(self, event):
354        if not self.zoomed:
355            self.activateZoom()
356
357        self.update()
358
359    def mousePressEvent(self, event):
360        if event.buttons() != Qt.LeftButton:
361            return
362
363        self.pressed = self.snapped = True
364        self.pressPos = self.dragPos = event.pos()
365        self.tapTimer.stop()
366        self.tapTimer.start(HOLD_TIME, self)
367
368    def mouseMoveEvent(self, event):
369        if not event.buttons():
370            return
371
372        if not self.zoomed:
373            if not self.pressed or not self.snapped:
374                delta = event.pos() - self.pressPos
375                self.pressPos = event.pos()
376                self._normalMap.pan(delta)
377                return
378            else:
379                threshold = 10
380                delta = event.pos() - self.pressPos
381                if self.snapped:
382                    self.snapped &= delta.x() < threshold
383                    self.snapped &= delta.y() < threshold
384                    self.snapped &= delta.x() > -threshold
385                    self.snapped &= delta.y() > -threshold
386
387                if not self.snapped:
388                    self.tapTimer.stop()
389
390        else:
391            self.dragPos = event.pos()
392            self.update()
393
394    def mouseReleaseEvent(self, event):
395        self.zoomed = False
396        self.update()
397
398    def keyPressEvent(self, event):
399        if not self.zoomed:
400            if event.key() == Qt.Key_Left:
401                self._normalMap.pan(QPoint(20, 0))
402            if event.key() == Qt.Key_Right:
403                self._normalMap.pan(QPoint(-20, 0))
404            if event.key() == Qt.Key_Up:
405                self._normalMap.pan(QPoint(0, 20))
406            if event.key() == Qt.Key_Down:
407                self._normalMap.pan(QPoint(0, -20))
408            if event.key() == Qt.Key_Z or event.key() == Qt.Key_Select:
409                self.dragPos = QPoint(self.width() / 2, self.height() / 2)
410                self.activateZoom()
411        else:
412            if event.key() == Qt.Key_Z or event.key() == Qt.Key_Select:
413                self.zoomed = False
414                self.update()
415
416            delta = QPoint(0, 0)
417            if event.key() == Qt.Key_Left:
418                delta = QPoint(-15, 0)
419            if event.key() == Qt.Key_Right:
420                delta = QPoint(15, 0)
421            if event.key() == Qt.Key_Up:
422                delta = QPoint(0, -15)
423            if event.key() == Qt.Key_Down:
424                delta = QPoint(0, 15)
425            if delta != QPoint(0, 0):
426                self.dragPos += delta
427                self.update()
428
429
430class MapZoom(QMainWindow):
431    def __init__(self):
432        super(MapZoom, self).__init__(None)
433
434        self.map_ = LightMaps(self)
435        self.setCentralWidget(self.map_)
436        self.map_.setFocus()
437        self.osloAction = QAction("&Oslo", self)
438        self.berlinAction = QAction("&Berlin", self)
439        self.jakartaAction = QAction("&Jakarta", self)
440        self.nightModeAction = QAction("Night Mode", self)
441        self.nightModeAction.setCheckable(True)
442        self.nightModeAction.setChecked(False)
443        self.osmAction = QAction("About OpenStreetMap", self)
444        self.osloAction.triggered.connect(self.chooseOslo)
445        self.berlinAction.triggered.connect(self.chooseBerlin)
446        self.jakartaAction.triggered.connect(self.chooseJakarta)
447        self.nightModeAction.triggered.connect(self.map_.toggleNightMode)
448        self.osmAction.triggered.connect(self.aboutOsm)
449
450        menu = self.menuBar().addMenu("&Options")
451        menu.addAction(self.osloAction)
452        menu.addAction(self.berlinAction)
453        menu.addAction(self.jakartaAction)
454        menu.addSeparator()
455        menu.addAction(self.nightModeAction)
456        menu.addAction(self.osmAction)
457
458    # slots
459    def chooseOslo(self):
460        self.map_.setCenter(59.9138204, 10.7387413)
461
462    def chooseBerlin(self):
463        self.map_.setCenter(52.52958999943302, 13.383053541183472)
464
465    def chooseJakarta(self):
466        self.map_.setCenter(-6.211544, 106.845172)
467
468    def aboutOsm(self):
469        QDesktopServices.openUrl(QUrl('http://www.openstreetmap.org'))
470
471
472if __name__ == '__main__':
473
474    import sys
475
476    app = QApplication(sys.argv)
477    app.setApplicationName('LightMaps')
478    w = MapZoom()
479    w.setWindowTitle("OpenStreetMap")
480    w.resize(600, 450)
481    w.show()
482    sys.exit(app.exec_())
483