Index de l'article

Générer des cartes

Nous allons maintenant générer 341 cartes en PDF, chacune zoomant sur nos 341 sommets (mais pour avancer plus efficacement, utilisez plutôt l'extraction nommée peaks_selection, en pièce jointe de cet article, seulement une dizaine de sommets choisis, qui permettra d'exécuter nos scripts plus rapidement).

Ces cartographies automatiques sont donc en quelque sorte d'une reproduction de la fonction Atlas de QGIS, mais nous allons pouvoir aller beaucoup plus loin : appliquer des zooms différents par cartes, des symbologies conditionnelles différentes, des classifications, ajouter des contenus web, utiliser des APIs, lancer des géo-traitements, jointures, intersections... Bref, c'est pas trop mal.

Et pour les plus pressés🧐, un exemple de code complet est disponible ici.

Sélection itérative

Précédemment nous sélectionnions notre sommet, star de notre carte, sur la base de son identifiant. Mais maintenant que nous souhaitons itérer sur toute la couche peaks, nous ne pouvons plus nommément utiliser les identifiants (en fait si : nous pourrions les écrire un par un dans le code, dans un IN, mais ce n'est pas le but du jeu, une entité ajoutée dans les données ne serait pas prise en compte, et une entité supprimée ferait bugger le code).

Il nous faut donc boucler sur les entités sommitales.

Inspirez vous de la Sélection sans fonction plus haut dans ce court, pour créer une sélection d'un sommet sans connaître son identifiant.

Votre sélection des sommets se trouvera donc dans une boucle. Nous ne pourrons donc plus zoomer ou nous déplacer manuellement sur la couche pour générer les canvas de nos cartes.

Pour plus de clarté commencez par mettre votre expression dans une variable à part (5ème ligne ci-dessous). Utilisez des print pour visualiser progressivement le contenu de vos variables.

mylayer = QgsProject.instance().mapLayersByName("peaks_selection")[0]
 
for feat in mylayer.getFeatures():
    id_peak = feat['OSM_ID']
    expr = u"OSM_ID = '{}'".format(id_peak)
    myselect = mylayer.getFeatures( QgsFeatureRequest().setFilterExpression ( expr ))
 
    mylayer.selectByIds( [ f.id() for f in myselect ] )
 
    iface.mapCanvas().zoomToSelected(mylayer)

Bien, vous êtes capable de lister les différents sommets à l'intérieur d'une boucle. 

Profitons-en pour ajouter une variable contenant leurs noms (champ NAME) et affichons là dans un print.

Contrôlons également le niveau de zoom avec la méthode zoomScale().

mylayer = QgsProject.instance().mapLayersByName("peaks_selection")[0]
 
for feat in mylayer.getFeatures():
    id_peak = feat['OSM_ID']
    peak_name = feat['NAME']
 
    expr = u"OSM_ID = '{}'".format(id_peak)
    myselect = mylayer.getFeatures( QgsFeatureRequest().setFilterExpression ( expr ))
    print(f"Voici {peak_name}" )
 
    mylayer.selectByIds( [ f.id() for f in myselect ] )
 
    iface.mapCanvas().zoomToSelected(mylayer)
    iface.mapCanvas().zoomScale(23000)

Notez que le code ci-dessous se termine en sélectionnant la dernière entité listée, mais il a bel et bien listé, sélectionné puis zoomé une par une sur chaque entité. Même si cela ne s'est pas vu !

QGIS en a même gardé une trace dans son cache : si vous jouez avec le bouton Zoom Précédent du menu de QGIS, puis examinez son nom en comparant avec votre liste print, vous vous en apercevrez.

Utiliser les données dans la carte

Maintenant vous allez adaptez dans cette boucle le code complet du chapitre Générer une carte, afin de nommer votre layout, votre carte, lui mettre un titre, l'id OSM en guise de sous-titre, et un export correctement nommé également.

Vous allez devoir sélectionner des blocs de code et jouer avec le bouton Tab (indentation) et l'alliance Shift+Tab (retrait d'indentation) afin d'indenter correctement votre code dans la nouvelle boucle.

Testez votre code de façon itérative et procédurale :

  • D'abord la création des layouts, allez ensuite vérifier qu'ils existent bien.
  • Ensuite le zoom de chaque layout sur chaque sommet, vérifiez-en quelques uns.
  • ...

Layouts, canvas et légende

Il vous faudra passer votre variable peak_name en chaîne de texte pour éviter des erreurs d'interprêtation de la variable layoutName. Utilisez la méthode str().

N'oubliez pas non plus de tout mettre dans la boucle ! Soit une indentation à ajouter au code ci-dessous.

...
layoutName = str(peak_name)
 
project = QgsProject.instance()
manager = project.layoutManager()
 
# Vérification de la non-existence d'un layout de même nom
layouts_list = manager.printLayouts()
for layout in layouts_list:
    if layout.name() == layoutName:
        manager.removeLayout(layout)
 
# Génération d'un layout vide
layout = QgsPrintLayout(project)
layout.initializeDefaults()
layout.setName(layoutName)
manager.addLayout(layout)
 
# Charger une carte vide
map = QgsLayoutItemMap(layout)
map.setRect(20, 20, 20, 20)
 
# Mettre un canvas basique
rectangle = QgsRectangle(1355502, -46398, 1734534, 137094)
map.setExtent(rectangle)
layout.addLayoutItem(map)
 
# Mettre finalement le canvas courant
canvas = iface.mapCanvas()
map.setExtent(canvas.extent())
layout.addLayoutItem(map)
 
# Redimensionner la carte
map.attemptMove(QgsLayoutPoint(5, 27, QgsUnitTypes.LayoutMillimeters))
map.attemptResize(QgsLayoutSize(220, 178, QgsUnitTypes.LayoutMillimeters))
map.setFrameEnabled(True)
 
# Légende personnalisée
tree_layers = project.layerTreeRoot().children()
checked_layers = [layer.name() for layer in tree_layers if layer.isVisible()]
# print(f"Je vais ajouter uniquement {checked_layers} à la légende." )
 
layers_to_remove = [layer for layer in project.mapLayers().values() if layer.name() not in checked_layers]
# print(f"Je vais retirer {layers_to_remove} de la légende." )
 
legend = QgsLayoutItemLegend(layout)
legend.setTitle("Légende")
 
legend.setLinkedMap(map)
 
layout.addLayoutItem(legend)
legend.attemptMove(QgsLayoutPoint(240, 24, QgsUnitTypes.LayoutMillimeters))
 
# Cette ligne permettra de ne pas sortir les couches inutilisées de votre panneau des calques QGIS, mais uniquement de la légende
legend.setAutoUpdateModel(False)
 
m = legend.model()
g = m.rootGroup()
for l in layers_to_remove:
    g.removeLayer(l)
 
# Ajuster la légende
legend.adjustBoxSize()
 
# Forcer la mise à jour de la légende
legend.updateLegend()
iface.mapCanvas().refresh()
...

Titres et sous-titres

La variable peak_name contenant le nom des sommets, à utiliser dans le titre de nos cartes, à déjà été passée en texte, avec la méthode str(). Elle peut donc être utilisée telle quelle.

Mais ce n'est pas le cas de l'identifiant OSM, que nous voulons ajouter dans le sous-titre. Nous allons le faire mais en y laissant un peu de texte, grâce à une chaîne formatée.

...
# Titre
title = QgsLayoutItemLabel(layout)
title.setText(layoutName)
title.setFont(QFont("Verdana", 28))
title.adjustSizeToText()
layout.addLayoutItem(title)
title.attemptMove(QgsLayoutPoint(5, 4, QgsUnitTypes.LayoutMillimeters))
title.attemptResize(QgsLayoutSize(220, 20, QgsUnitTypes.LayoutMillimeters))
 
# Sous-titre
subtitle = QgsLayoutItemLabel(layout)
subtitle.setText("Identifiant OSM : %s" % (str(id_peak)))
subtitle.setFont(QFont("Verdana", 17))
subtitle.adjustSizeToText()
layout.addLayoutItem(subtitle)
subtitle.attemptMove(QgsLayoutPoint(5, 19, QgsUnitTypes.LayoutMillimeters))
subtitle.attemptResize(QgsLayoutSize(220, 20, QgsUnitTypes.LayoutMillimeters))
...

Échelle

L'échelle doit être légèrement adaptée, puisque nous avons modifié le niveau de zoom :

...
# Échelle
scalebar = QgsLayoutItemScaleBar(layout)
scalebar.setStyle('Single Box')
scalebar.setUnits(QgsUnitTypes.DistanceKilometers)
scalebar.setNumberOfSegments(2)
scalebar.setNumberOfSegmentsLeft(0)
scalebar.setUnitsPerSegment(1)
scalebar.setLinkedMap(map)
scalebar.setUnitLabel('km')
scalebar.setFont(QFont('Verdana', 20))
scalebar.update()
layout.addLayoutItem(scalebar)
scalebar.attemptMove(QgsLayoutPoint(10, 185, QgsUnitTypes.LayoutMillimeters))
...

Logo

Le logo ne change pas :

...
# Logo
Logo = QgsLayoutItemPicture(layout)
Logo.setPicturePath("https://master-geomatique.org/templates/theme3005/images/logo-ucp-cyu.png")
layout.addLayoutItem(Logo)
Logo.attemptResize(QgsLayoutSize(40, 15, QgsUnitTypes.LayoutMillimeters))
Logo.attemptMove(QgsLayoutPoint(250,4, QgsUnitTypes.LayoutMillimeters))
...

Texte

Le texte fera l'objet d'un chapite dédié, ne le mettez pas pour l'instant.

Le bug de la 1ère carte

Dépendant du niveau de zoom mentionné dans votre code, mais aussi de la projection utilisée dans votre projet, à la 1ère ouverture de QGIS et à la 1ère exécution du code, avec le code tel quel, la 1ère carte exportée, a minima, ne va pas prendre le niveau de zomm souhaité, oups...

En fonction de votre situation (puisque dépendant de beaucoup de choses, mdr), à la 2nd exécution du code, ce bug va être corrigé.

Mouais, pas très pro cette histoire... Nous allons donc forcer la mise à jour du niveau de zoom, non pas seulement dans le canvas, mais dans l'objet carte (map), cela avec un map.setExtent et un map.setScale :

...
# Zoom on single peak in canvas
extent = sommet_unique.extent()
iface.mapCanvas().setExtent(extent)
iface.mapCanvas().zoomScale(20000)
iface.mapCanvas().refresh()
 
# Zoom on single peak in the map (to force zoom on the 1st map)
map.setExtent(iface.mapCanvas().extent())
map.setScale(100000)
 
# Layout
layout.addLayoutItem(map)
...

Export

Là encore, chaîne formatée pour correctement nommer nos carte grâce à la variable layoutName.

Attention : le répertoire d'accueil de vos cartes doit exister !

...
# Export PDF
manager = QgsProject.instance().layoutManager()
layout = manager.layoutByName(layoutName)
exporter = QgsLayoutExporter(layout)
exporter.exportToPdf("C:/Users/georg/Downloads/cartes/%s.pdf" % (layoutName),QgsLayoutExporter.PdfExportSettings())

Le bug des layouts

Bien, on a déjà de belles cartes. Mais si vous allez checker les mises en page dans le gestionnaire d'impressions, vous verrez que malgré le bon export des carte, chaque impression a pris la légende et la symbologie du dernier sommet, arf...

Ceci n'est pas un bug, mais tient au comportement des renderers (renderer()). En effet à chaque fois qu'on lance un renderer sur une couche, celui-ci s'applique naturellement à toute la couche, puis vient mettre à jour toutes les impressions utilisant cette couche, yep 🤣

Logiquement donc, à chaque itération de la boucle, la carte est bien exporté à partir de l'impression sur le sommet itéré, mais toutes les impressions sont aussi mises à jour sur le sommet itéré.

Cela est donc sans conséquence sur nos exports et nous resteront ainsi. Si cela était un vrai problème, en fonction du contexte, il est possible de gérer les symbologies autrement, avec des presets ou des themes de cartes.

Date du jour

Nous allons ajouter la date du jour dans les noms de nos fichiers.

Ouvrez la console de votre machine (CMD si vous êtes sous Windows) et entrez simplement:

python

La console passe en mode Python et affiche quelques informations de version. Maintenant ce code pour récupérer la date du jour :

import datetime
date = datetime.date.today()
str(date)

Le même code va fonctionner dans la console Python de QGIS. La 1ère ligne est une importation d'un module natif, à inclure en haut de votre fichier.

Le date peut être stockée hors de la boucle, nous la stockerons dans une variable today.

import datetime
mylayer = QgsProject.instance().mapLayersByName("peaks_selection")[0]
 
date = datetime.date.today()
today = str(date)
 
for feat in mylayer.getFeatures():
    id_peak = feat['OSM_ID']
    peak_name = feat['NAME']
 
...

Avant de l'inclure dans le nom de nos exports dans la chaîne formatée.

...
exporter.exportToPdf\
("C:/Users/georg/Downloads/cartes/%s-%s.pdf"\
% (today, layoutName),QgsLayoutExporter.PdfExportSettings())

Bien, pour y voir plus clair, supprimez manuellement toutes vos cartes PDF existantes et lancez votre code.

Ça ne marche pas trop mal😎, mais nous pouvons encore aller plus loin, et sortir ainsi d'un simple atlas.

 

Liens ou pièces jointes
Accéder à cette adresse URL (https://hg-map.fr/extern/data/shapes/france/chemin_de_fer.zip)chemin_de_fer.zip[ ]0 Ko
Télécharger ce fichier (data_BDTOPO_V3_Dep05_adresse.zip)data_BDTOPO_V3_Dep05_adresse.zip[ ]3889 Ko
Télécharger ce fichier (data_IRIS_2019.zip)data_IRIS_2019.zip[ ]45905 Ko
Télécharger ce fichier (decathlon_france.zip)decathlon_france.zip[308 magasins Décathlon français depuis OSM le 27 décembre 2020]11 Ko
Accéder à cette adresse URL (https://hg-map.fr/extern/data/shapes/france/eau.zip)eau.zip[ ]0 Ko
Télécharger ce fichier (glaciers.zip)glaciers.zip[ ]231 Ko
Télécharger ce fichier (iso_iris.zip)iso_iris.zip[Des zones isochrones à 15 minutes autour de 308 POIs.]12125 Ko
Télécharger ce fichier (Koln GML.zip)Koln gml.zip[ ]2818 Ko
Télécharger ce fichier (peaks.zip)peaks.zip[ ]14 Ko
Télécharger ce fichier (peaks_selection.zip)peaks_selection.zip[ ]1 Ko
Télécharger ce fichier (simple_countries.zip)simple_countries.zip[ ]1880 Ko
Accéder à cette adresse URL (https://hg-map.fr/extern/data/shapes/france/sol.zip)sol.zip[ ]0 Ko
Accéder à cette adresse URL (https://hg-map.fr/extern/data/shapes/france/troncons_routes.zip)troncons_routes.zip[ ]0 Ko
Télécharger ce fichier (World Stats.xlsx)World Stats[ ]27 Ko