Carte et points de passage

JaVelo – étape 8

1. Introduction

Le but de cette huitième étape est de commencer l'interface graphique de JaVelo en réalisant une classe gérant le fond de carte, et une autre gérant les points de passage.

2. Concepts

Même s'il n'y a que peu de concepts réellement nouveaux à introduire pour cette étape, il convient de décrire en détail comment se comporte l'interface utilisateur de JaVelo, afin que vous sachiez comment la réaliser.

2.1. Carte

L'interaction avec la carte affiché dans l'interface de JaVelo s'effectue de la même manière qu'avec les systèmes cartographiques en ligne (OpenStreetMap et autres) :

  • l'échelle (niveau de zoom) de la carte peut être changée au moyen de la molette de la souris,
  • la portion de carte affichée peut être changée en maintenant pressé le bouton gauche de la souris en déplaçant cette dernière.

Ces deux types d'interactions sont présentées dans la vidéo ci-dessous.

Il faut noter que lorsque l'échelle de la carte change, le point situé sous le curseur de la souris ne bouge pas. Cela est bien visible dans la vidéo ci-dessus, car l'échelle de la carte y est changée à deux reprises, la première fois avec le curseur de la souris positionné au nord de l'EPFL, la seconde fois avec le curseur de la souris positionné sur Sauvabelin.

2.2. Points de passage

L'ajout, la suppression et le déplacement des points de passage s'effectue également au moyen de la souris, de la manière suivante :

  • un nouveau point de passage peut être ajouté à la suite des éventuels points existants en cliquant à l'endroit désiré sur la carte,
  • un point de passage peut être déplacé en amenant le pointeur de la souris sur lui, jusqu'à ce qu'il se mette en évidence, puis en maintenant pressé le bouton gauche de la souris en déplaçant cette dernière,
  • un point de passage peut être supprimé en cliquant sur lui.

Le point de passage le plus ancien est toujours celui de départ de l'itinéraire, et est affiché en vert. S'il y a au moins deux points de passage, le plus récent est toujours celui d'arrivée, et est affiché en rouge. Les autres sont les points intermédiaires, et sont affichés en bleu.

La vidéo ci-dessous illustre la manière dont les points de passage peuvent être ajoutés, déplacés ou supprimés. (Les animations qui apparaissent lors des clics de la souris ont été ajoutées pour les visualiser et sont absentes de l'interface de JaVelo.)

2.3. Chemins SVG

Comme la vidéo ci-dessus l'illustre, les points de passage sont représentés au moyen de marqueurs colorés. La forme de ces marqueurs est décrite au moyen de ce que l'on nomme des chemins SVG (SVG paths), des séquences de commandes simples permettant de décrire la forme d'une courbe en deux dimensions.

Le format des chemins SVG ne sera pas décrit ici, mais il faut savoir que JavaFX permet d'utiliser un tel chemin pour décrire la forme d'un nœud, et nous utiliserons cette possibilité pour dessiner les marqueurs.

Un marqueur est composé de deux chemins : un premier pour le contour du marqueur, et un second pour le cercle intérieur. Le chemin du contour est décrit par la chaîne suivante :

M-8-20C-5-14-2-7 0 0 2-7 5-14 8-20 20-40-20-40-8-20

Le chemin du cercle intérieur est quant à lui décrit par la chaîne suivante :

M0-23A1 1 0 000-29 1 1 0 000-23

Pour visualiser les courbes représentées par ces chemins, on peut les charger dans le site SVG path editor.

2.4. Feuilles de style JavaFX

Comme toute bibliothèque graphique, JavaFX permet de contrôler l'apparence — taille, couleur, etc. — des différents éléments présents à l'écran. Ce contrôle peut s'effectuer de différentes manières, l'une d'entre elles consistant à utiliser des feuilles de style (style sheets), selon un principe similaire à celui utilisé pour contrôler l'apparence des pages Web.

Nous utiliserons exclusivement de telles feuilles de style pour contrôler l'aspect de l'interface graphique de JaVelo. Les feuilles de style à utiliser vous sont distribuées dans le cadre de cette étape, ce qui simplifiera votre travail.

Le fonctionnement détaillé des feuilles de style ne sera pas décrit ici, car cela sort du cadre du cours. Une rapide introduction est toutefois nécessaire afin de pouvoir les utiliser correctement. Les personnes intéressées par plus de détails se rapporteront au document JavaFX CSS Reference Guide.

Une feuille de style JavaFX est composée d'un certain nombre de règles (rules), chacune d'entre elles décrivant l'apparence d'un ou plusieurs éléments graphiques, que JavaFX nomme nœuds (nodes). Les nœuds auxquels s'applique une règle sont déterminés par le sélecteur (selector) attaché à la règle.

Les feuilles de style utilisées dans le cadre de ce projet utilisent principalement trois types de sélecteurs pour désigner les nœuds auxquels une règle s'applique. Chacun de ces sélecteurs utilise un attribut différent du nœud, qui est :

  1. leur identité (id), ou
  2. leurs classes de style (style classes), ou
  3. leur type Java.

L'identité d'un nœud JavaFX est une chaîne de caractères qui lui est attachée au moyen de la méthode setId. L'identité d'un objet doit permettre de l'identifier de manière unique dans un graphe de scène, c-à-d qu'il ne doit pas y avoir plus d'un nœud appartenant à un graphe de scène ayant une identité donnée.

Les classes des style d'un nœud JavaFX sont une liste (!) de chaînes de caractères attachées au nœud et que l'on peut obtenir au moyen de la méthode getStyleClass. La valeur retournée a le type ObservableList, et implémente entre autres le type List de la bibliothèque Java. Il est donc possible de manipuler les classes de style d'un nœud en utilisant les méthodes de List sur cette liste (add, remove, set, etc.).

Le type Java d'un nœud est simplement le type de la classe JavaFX représentant ce nœud, p.ex. Rectangle pour un rectangle.

Par exemple, une des feuilles de style que nous vous fournissons contient la règle suivante :

.pin_outside {
    -fx-stroke: rgb(0, 0, 0, 0.5);
    -fx-stroke-width: 1;
}

Cette règle utilise le sélecteur .pin_outside signifiant qu'elle s'applique à tous les nœuds ayant pin_outside parmi leurs classes de style. Comme nous le verrons plus bas, cette classe de style est attachée aux chemins SVG représentant le contour des marqueurs.

En raison de cette règle, ces chemins seront dessinés au moyen d'un trait noir semi-transparent (-fx-stroke: …), d'une largeur d'une unité (-fx-stroke-width: 1).

3. Mise en œuvre Java

Nous vous conseillons fortement de réaliser cette étape en la découpant en trois phases successives consistant à :

  1. écrire la partie « vue » des deux classes décrites plus bas, à savoir le code qui dessine le fond de carte dans BaseMapManager et le code qui dessine les marqueurs de point de passage dans WaypointsManager,
  2. vérifier que ces deux classes affichent bien ce qu'elles doivent afficher, au moyen du petit programme de test donné à la §3.4,
  3. ajouter la partie « contrôleur » aux deux classes, en écrivant les gestionnaires d'événements nécessaires.

Procéder ainsi facilitera votre travail et vous permettra d'être plus en phase avec le cours, étant donné que les gestionnaires d'événements s'écrivent généralement sous le forme de lambdas.

3.1. Importation des ressources

Avant de commencer cette étape, il vous faut importer dans votre projet le contenu d'une archive Zip que nous vous fournissons et qui contient trois feuilles de style JavaFX, dont une est déjà utile à cette étape (map.css), les deux autres n'étant utiles que plus tard.

Ces fichiers constituent ce que l'on nomme les ressources (resources) du projet. Le répertoire resources dans lequel ces fichiers se trouvent doit être ajouté à votre projet comme d'habitude, en suivant les indications données dans nos guides pour IntelliJ (sans oublier le point 7, en marquant le répertoire comme Resources Root) ou pour Eclipse (en faisant bien attention aux points 5 à 7).

3.2. Classe BaseMapManager

La classe BaseMapManager du sous-paquetage gui, publique et finale, gère l'affichage et l'interaction avec le fond de carte. Elle possède un constructeur public qui prend en arguments :

  • le gestionnaire de tuiles à utiliser pour obtenir les tuiles de la carte,
  • le gestionnaire des points de passage (décrit à la §3.3),
  • une propriété JavaFX contenant les paramètres de la carte affichée.

En attendant d'avoir écrit la classe WaypointsManager, vous pouvez omettre cet argument de votre constructeur.

En plus de ce constructeur public, BaseMapManager n'offre qu'une seule méthode publique, nommée p. ex. pane, retournant le panneau JavaFX affichant le fond de carte.

3.2.1. Conseils de programmation

  1. Hiérarchie JavaFX

    La carte est dessinée sur une instance de Canvas — la classe JavaFX représentant un « canevas », c.-à-d. une surface sur laquelle il est possible de dessiner — placée dans un panneau de type Pane.

    La raison pour laquelle il est nécessaire de placer le canevas dans un panneau est que, contrairement au canevas, le panneau est redimensionné automatiquement lorsqu'il est placé dans certains composants. Cela est utile pour garantir que le panneau (et donc la carte) occupe la totalité de la fenêtre de l'interface.

    Sachant que le canevas n'est pas redimensionné automatiquement, il faut utiliser des liens JavaFX (bindings) pour faire en sorte que sa largeur et sa hauteur soient toujours égales à celles du panneau. Pour la largeur, cela peut se faire ainsi :

    canvas.widthProperty().bind(pane.widthProperty());
    
  2. Dessin de la carte

    Pour dessiner sur un canevas, il faut obtenir ce que l'on nomme son contexte graphique (graphics context) au moyen de la méthode getGraphicsContext2D. Ce contexte, de type GraphicsContext, possède de nombreuses méthodes permettant de définir les paramètres de dessin (couleur du trait, épaisseur, etc.) et de dessiner.

    Pour dessiner la carte, une fois le contexte graphique obtenu, on peut utiliser sa méthode drawImage pour dessiner chacune des tuiles visible au moins partiellement. Ces tuiles sont obtenues du gestionnaire de tuiles, et si ce dernier lève une exception (de type IOException), la tuile correspondante n'est simplement pas dessinée.

  3. Redessin efficace

    La carte doit être redessinée à chaque changement des paramètres de la carte affichée (niveau de zoom ou coin haut-gauche), ainsi qu'à chaque changement des dimensions du canevas.

    Le dessin de la carte étant relativement cher, il est important de le retarder autant que possible. Par exemple, si l'utilisateur redimensionne rapidement la fenêtre, il faudrait éviter de redessiner la totalité de la carte au moindre changement.

    Pour cela, nous vous proposons une technique qui consiste à retarder le redessin jusqu'au prochain « battement » (pulse) JavaFX. Sans rentrer dans les détails, JavaFX met généralement à jour le dessin de l'interface 60 fois par seconde, si cela est nécessaire. Chacune de ces mises à jour est nommée un « battement ». En retardant le redessin de la carte au prochain battement, on garantit qu'elle n'est pas redessinée plus de 60 fois par seconde.

    Retarder ainsi le redessin n'est pas très facile et utilise des notions avancées de JavaFX, donc nous vous offrons ici une solution « clefs en main ». Il vous faut dans un premier temps ajouter à votre classe un attribut booléen nommé redrawNeeded, qui ne sera vrai que si un redessin de la carte est nécessaire. Ensuite, il vous faut y ajouter une méthode privée nommée redrawIfNeeded effectuant le redessin si et seulement si cet attribut est vrai :

    private void redrawIfNeeded() {
      if (!redrawNeeded) return;
      redrawNeeded = false;
    
      // … à faire : dessin de la carte
    }
    

    Ensuite, il faut vous assurer que JavaFX appelle bien cette méthode à chaque battement, ce qui se fait au moyen du code ci-dessous, à ajouter au constructeur de votre classe :

    canvas.sceneProperty().addListener((p, oldS, newS) -> {
        assert oldS == null;
        newS.addPreLayoutPulseListener(this::redrawIfNeeded);
      });
    

    Finalement, il faut ajouter une méthode privée à votre classe permettant de demander un redessin au prochain battement. Cela se fait en mettant à vrai l'attribut redrawNeeded, et en forçant JavaFX à effectuer le prochain battement, même si de son point de vue cela n'est pas nécessaire :

    private void redrawOnNextPulse() {
      redrawNeeded = true;
      Platform.requestNextPulse();
    }
    

    Une fois tout cela en place, il ne vous reste plus qu'à appeler la méthode redrawOnNextPulse lorsqu'un redessin est nécessaire.

  4. Gestion des événements

    BaseMapManager doit détecter et gérer trois types d'événements :

    1. l'utilisation de la molette de la souris, qui permet de changer le niveau de zoom de la carte,
    2. le déplacement de la souris lorsque le bouton gauche est maintenu pressé, qui permet de faire glisser la carte,
    3. le clic sur la carte, qui permet d'ajouter un point de passage.

    Les deux derniers types d'événements sont similaires puisqu'ils impliquent tous deux un clic, la différence étant que dans un cas la souris bouge entre le moment où le bouton est pressé et celui où il est relâché, alors que dans l'autre cas elle reste stationnaire.

    Pour gérer ces types d'événements, il faut installer des gestionnaires sur le panneau contenant la carte, au moyen des méthodes setOnScroll (changement du niveau de zoom), setOnMousePressed, …Dragged, …Released (glissement de la carte) et setOnMouseClicked (ajout d'un point de passage).

    Comme tous les gestionnaires d'événements JavaFX, ceux qu'on installe au moyen de ces méthodes reçoivent en argument un événement, de type MouseEvent pour tous les gestionnaires susmentionnés sauf celui passé à setOnScroll, qui reçoit un événement de type ScrollEvent. Ces événements possèdent plusieurs méthodes utiles parmi lesquelles :

    • getX() et getY(), qui permettent de connaître la position du curseur de la souris,
    • isStillSincePress(), qui permet de savoir si la souris est restée stationnaire depuis le moment où son bouton a été pressé,
    • getDeltaY() (ScrollEvent uniquement), qui permet de savoir de combien d'unités la molette de la souris a été tournée.

    La valeur retournée par getDeltaY est à ajouter au niveau de zoom courant pour obtenir, après arrondissement à l'entier le plus proche, le nouveau niveau de zoom, qui doit toutefois être limité à la plage allant de 8 à 19. Comme cela a été mentionné plus haut, le point se trouvant sous le pointeur de la souris ne doit pas bouger lorsque le niveau de zoom change.

    Les trois gestionnaires d'événements gérant le glissement de la carte doivent déterminer de combien d'unités la souris s'est déplacée le long des deux axes. Un moyen simple de faire cela est de stocker dans une propriété JavaFX la position à laquelle se trouvait la souris lors de l'événement précédent, représentée par une valeur de type Point2D. Cette classe possède des méthodes (add, subtract, etc.) permettant de facilement faire les calculs nécessaires.

    En plus de ces gestionnaires d'événements, BaseMapManager doit également mettre en place des auditeurs JavaFX qui détectent les situations dans lesquelles le fond de carte doit être redessiné, et appeler redrawOnNextPulse dans ce cas. Cela se fait facilement en ajoutant un auditeur détectant les changements des paramètres du fond de carte et des dimensions du canevas — c.-à-d. ses propriétés width et height.

3.3. Classe WaypointsManager

La classe WaypointsManager, du sous-paquetage gui, publique et finale, gère l'affichage et l'interaction avec les points de passage. Elle possède un unique constructeur public qui prend en arguments :

  • le graphe du réseau routier,
  • une propriété JavaFX contenant les paramètres de la carte affichée,
  • la liste (observable) de tous les points de passage,
  • un objet permettant de signaler les erreurs (voir la §3.3.1.4).

En plus de ce constructeur, WaypointsManager n'offre que deux méthodes publiques :

  • une méthode, nommée p. ex. pane, qui retourne le panneau contenant les points de passage,
  • une méthode, nommée p. ex. addWaypoint, qui prend en arguments les coordonnées x et y d'un point et ajoute un nouveau point de passage au nœud du graphe qui en est le plus proche.

La méthode addWaypoint recherche le nœud le plus proche dans un cercle de 1000 m de diamètre centré sur le point donné. Si aucun nœud n'y est trouvé, elle signale une erreur au moyen de la technique décrite à la §3.3.1.4.

3.3.1. Conseils de programmation

  1. Hiérarchie JavaFX

    Chaque marqueur représentant un point de passage est un groupe JavaFX, de type Group, contenant deux fils, qui sont chacun un chemin SVG de type SVGPath. Les groupes représentant les marqueurs sont les fils directs d'un panneau de type Pane.

    Le premier de ces chemins est le contour extérieur du marqueur, et la classe de style pin_outside lui est attachée. Le second de ces chemins est le disque blanc intérieur du marqueur, et la classe de style pin_inside lui est attachée. Finalement, la classe de style pin est attachée au groupe lui-même, de même que la classe first s'il s'agit du premier point de passage, last s'il s'agit du dernier, et middle sinon.

    La méthode setContent de SVGPath peut être utilisée pour donner le texte du chemin SVG à un nœud de ce type.

  2. Positionnement des marqueurs

    Les chemins SVG des marqueurs ont été construits de manière à ce que leur origine se trouve au bout de leur pointe. Pour les placer correctement sur la carte, il suffit donc de les positionner aux coordonnées du point de passage qui leur correspond.

    Ce positionnement doit obligatoirement être fait au moyen des méthodes setLayoutX et setLayoutY, appliquées aux groupes représentant les marqueurs. Les coordonnées à passer à ces méthodes sont exprimées dans le système du panneau contenant tous les marqueurs, qui est identique à celui du panneau montrant la carte car les deux panneaux sont toujours superposés — voir le programme de test de la §3.4 pour un exemple.

  3. Gestion des événements

    WaypointsManager doit détecter et gérer deux types d'événements :

    1. le déplacement de la souris lorsque le bouton gauche est maintenu pressé sur un marqueur de point de passage, qui permet de le déplacer,
    2. le clic sur un marqueur de point de passage, qui permet de le supprimer.

    Les gestionnaires d'événements correspondants doivent être installés sur chacun des groupes représentant un marqueur. Pour éviter que ces gestionnaires n'empêchent ceux du fond de carte d'être activés, il faut impérativement appeler la méthode setPickOnBounds avec false en argument sur le panneau contenant les marqueurs.

    Lorsqu'un marqueur est en cours de déplacement, il est effectivement déplacé visuellement, mais le point de passage auquel il correspond ne change pas avant que le bouton de la souris ne soit relâché. À ce moment-là, le nœud le plus proche du pointeur de la souris est recherché dans un cercle de 1000 m de diamètre centré sur lui, et s'il existe, le point de passage est changé. Sinon, une erreur est signalée (voir la section suivante) et le point de passage ne change pas.

    En plus de ces gestionnaires d'événements, WaypointsManager doit également mettre en place des auditeurs qui détectent les situations dans lesquelles les marqueurs doivent être recréés et/ou repositionnés, à savoir :

    1. un auditeur détectant les changements de la propriété contenant les paramètres du fond de carte et repositionnant les marqueurs,
    2. un auditeur détectant les changements de la liste contenant les points de passage et recréant la totalité des marqueurs.

    Pour faciliter l'écriture de ces auditeurs, il paraît judicieux de placer le code créant les marqueurs dans une première méthode privée, et le code les positionnant dans une seconde méthode privée.

  4. Gestion des erreurs

    Lorsqu'un point de passage est ajouté ou déplacé à un endroit donné et qu'aucun nœud ne se trouve proche de cet endroit, une erreur est signalée et l'action est annulée.

    Pour pouvoir signaler des erreurs, le constructeur de WaypointsManager reçoit en argument une valeur de type Consumer<String>. L'interface Consumer fait partie de la bibliothèque Java et représente un « consommateur de valeurs » d'un type donné. Sa méthode accept prend justement une valeur et la consomme, dans le sens où elle ne retourne aucun résultat.

    Le consommateur passé au constructeur de WaypointsManager prend en argument un message d'erreur, sous la forme d'une chaîne de caractères, et se charge de l'afficher à l'écran pour informer l'utilisateur du problème. Dans le programme de test donné plus bas, ce consommateur se contente d'afficher le message sur la console, mais dans la version finale du projet, le message sera affiché dans l'interface graphique.

    Les deux cas dans lesquels WaypointsManager doit signaler une erreur sont :

    1. lorsqu'on demande à addWaypoint d'ajouter un point de passage à un endroit qui n'est pas assez proche d'un nœud du graphe,
    2. lorsqu'un marqueur de point de passage est déplacé à un endroit qui n'est pas assez proche d'un nœud du graphe.

    Dans ces deux cas, le message à passer au consommateur est le même :

    Aucune route à proximité !
    

3.4. Tests

Pour tester cette étape, vous pouvez vous inspirer du programme ci-dessous, qu'il vous faudra certainement adapter à vos classes.

public final class Stage8Test extends Application {
  public static void main(String[] args) { launch(args); }

  @Override
  public void start(Stage primaryStage) throws Exception {
    Graph graph = Graph.loadFrom(Path.of("lausanne"));
    Path cacheBasePath = Path.of(".");
    String tileServerHost = "tile.openstreetmap.org";
    TileManager tileManager =
      new TileManager(cacheBasePath, tileServerHost);

    MapViewParameters mapViewParameters =
      new MapViewParameters(12, 543200, 370650);
    ObjectProperty<MapViewParameters> mapViewParametersP =
      new SimpleObjectProperty<>(mapViewParameters);
    ObservableList<Waypoint> waypoints =
      FXCollections.observableArrayList(
	new Waypoint(new PointCh(2532697, 1152350), 159049),
	new Waypoint(new PointCh(2538659, 1154350), 117669));
    Consumer<String> errorConsumer = new ErrorConsumer();

    WaypointsManager waypointsManager =
      new WaypointsManager(graph,
			   mapViewParametersP,
			   waypoints,
			   errorConsumer);
    BaseMapManager baseMapManager =
      new BaseMapManager(tileManager,
			 waypointsManager,
			 mapViewParametersP);

    StackPane mainPane =
      new StackPane(baseMapManager.pane(),
		    waypointsManager.pane());
    mainPane.getStylesheets().add("map.css");
    primaryStage.setMinWidth(600);
    primaryStage.setMinHeight(300);
    primaryStage.setScene(new Scene(mainPane));
    primaryStage.show();
  }

  private static final class ErrorConsumer
      implements Consumer<String> {
    @Override
    public void accept(String s) { System.out.println(s); }
  }
}

En exécutant ce programme, vous devriez voir une fenêtre similaire à celle ci-dessous s'afficher à l'écran.

stage8test-window;64.png

Bien entendu, tant que vous n'aurez pas écrit le code des gestionnaires d'événements, il ne sera pas possible d'interagir avec les éléments affichés.

4. Résumé

Pour cette étape, vous devez :

  • écrire les classes BaseMapManager et WaypointsManager selon les indications données ci-dessus,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.

Aucun rendu n'est à faire pour cette étape avant le rendu final. N'oubliez pas de faire régulièrement des copies de sauvegarde de votre travail en suivant nos indications à ce sujet.