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