Affichage du profil

JaVelo – étape 10

1. Introduction

Cette dixième et avant-dernière étape a pour but d'écrire la classe gérant l'affichage du profil en long de l'itinéraire, et l'interaction avec lui.

2. Concepts

2.1. Affichage du profil

Dans l'interface de JaVelo, la partie inférieure de la fenêtre affiche une représentation graphique du profil en long de l'itinéraire courant, ainsi que quelques statistiques le concernant – longueur, dénivelé, etc. Une grille est superposée au profil, et les lignes de cette grille étiquetées dans la marge.

La figure 1 ci-dessous montre comment le profil de l'itinéraire allant de l'EPFL à Sauvabelin, présenté à l'étape 6, est représenté par cette partie de l'interface.

profile-display;16.png
Figure 1 : Affichage du profil d'un itinéraire

Une position le long du profil peut être mise en évidence par une ligne verticale noire. Sur l'image ci-dessus, cette ligne se trouve à une position de 1500 m. Dans la version finale de l'interface, la position mise en évidence sur le profil au moyen de cette ligne est toujours identique à celle mise en évidence sur la carte au moyen du disque rouge et blanc.

Dans la figure ci-dessus, la grille est constituée de lignes verticales dessinées tous les kilomètres, et de lignes horizontales dessinées tous les 50 mètres. L'espacement utilisé entre les lignes dépend toutefois des caractéristiques du profil — longueur et plage d'altitudes couverte — et de la taille de sa représentation graphique.

Ainsi, les lignes verticales correspondant à la position peuvent être dessinées tous les
1, 2, 5, 10, 25, 50 ou 100 km, tandis que les lignes horizontales correspondant à l'altitude peuvent être dessinées tous les 5, 10, 20, 25, 50, 100, 200, 250, 500 ou 1000 m. La valeur utilisée dans les deux cas est la plus petite garantissant que, à l'écran, les lignes horizontales soient distantes d'au moins 25 unités JavaFX (pixels), et les verticales d'au moins 50 unités. (Si aucune valeur ne permet de garantir cela, la plus grande de toutes est utilisée.)

3. Mise en œuvre Java

La mise en œuvre de cette étape n'est pas excessivement compliquée, mais relativement longue. Nous vous conseillons donc de procéder petit à petit, en commençant par exemple par dessiner uniquement le profil lui-même — sans la grille ni la ligne de mise en évidence — avant d'ajouter les autres éléments.

3.1. Classe ElevationProfileManager

La classe ElevationProfileManager du sous-paquetage gui, publique et finale, gère l'affichage et l'interaction avec le profil en long d'un itinéraire. Elle possède un unique constructeur public prenant en arguments :

  • une propriété, accessible en lecture seule, contenant le profil à afficher ; elle est de type ReadOnlyObjectProperty<ElevationProfile> et contient null dans le cas où aucun profil n'est à afficher,
  • une propriété, accessible en lecture seule, contenant la position le long du profil à mettre en évidence ; elle est de type ReadOnlyDoubleProperty et contient NaN dans le cas où aucune position n'est à mettre en évidence.

En plus de ce constructeur, elle offre les méthodes publiques suivantes :

  • une méthode, nommée p. ex. pane, qui retourne le panneau contenant le dessin du profil,
  • une méthode, nommée p. ex. mousePositionOnProfileProperty, qui retourne une propriété en lecture seule contenant la position du pointeur de la souris le long du profil (en mètres, arrondie à l'entier le plus proche), ou NaN si le pointeur de la souris ne se trouve pas au-dessus du profil.

On considère que le pointeur de la souris se trouve au-dessus du profil s'il se trouve dans le rectangle bleu de la figure 3 plus bas.

Attention à ne pas confondre la propriété contenant la position à mettre en évidence, passée au constructeur, avec celle donnant la position du curseur de la souris au-dessus du profil, retournée par la méthode ci-dessus. Ces deux propriétés sont indépendantes et peuvent très bien avoir des valeurs différentes.

3.1.1. Conseils de programmation

  1. Hiérarchie JavaFX

    La hiérarchie de l'affichage du profil étant plus complexe que celle des autres parties de l'interface, elle est présentée graphiquement dans la figure 2 ci-dessous.

    Dans cette figure, les rectangles représentent des nœuds JavaFX, et les flèches qui les lient vont des parents aux enfants. Certains nœuds sont accompagnés d'une étiquette dont la couleur détermine la signification :

    • en vert, les feuilles de style CSS à attacher au nœud en modifiant la liste retournée par getStylesheets,
    • en rouge, l'identité des nœuds, à définir au moyen de la méthode setId,
    • en bleu, les classes de style à associer au nœud en modifiant la liste retournée par getStyleClass.
    profile-pane-hierarchy;32.png
    Figure 2 : Hiérarchie de l'affichage du profil

    Les feuilles de cette hiérarchie sont les nœuds représentant les différents éléments graphiques de l'affichage du profil. De gauche à droite, il s'agit de :

    • un chemin (de type Path) représentant la grille,
    • un certain nombre d'étiquettes textuelles (de type Text) regroupées dans un groupe (de type Group) et représentant les étiquettes de la grille,
    • un polygone (de type Polygon) représentant le graphe du profil,
    • une ligne (de type Line) représentant la position mise en évidence,
    • un texte (de type Text) contenant les statistiques du profil.

    Tous ces éléments sauf le dernier sont regroupés dans un panneau de type Pane, tandis que le dernier est placé dans un panneau de type VBox. Ces deux conteneurs sont placés respectivement dans la zone centrale et inférieure du panneau de type BorderPane à la racine de la hiérarchie. C'est ce dernier qui est retourné par la méthode pane.

    Le polygone représentant le profil est composé d'un point par unité JavaFX dans sa partie supérieure, et de deux points additionnels dans sa partie inférieure, situés aux extrémités gauche et droite et à la position correspondant à l'altitude minimale du profil. Ces deux points additionnels ont pour but de rendre la partie inférieure du profil semi-rectangulaire.

  2. Rectangle contenant le profil

    La plus grande partie du panneau montrant le dessin du profil est occupée par le profil lui-même, comme le montre la figure ci-dessous sur laquelle il est entouré d'un rectangle bleu.

    profile-display-frame;32.png
    Figure 3 : Rectangle contenant le profil

    La distance entre le bord supérieur du rectangle bleu et le bord supérieur du panneau le contenant est de 10 unités JavaFX (pixels), de même que la distance entre leurs deux bords droits ; la distance entre leurs bords inférieurs est de 20 unités ; et finalement, la distance entre leurs bords gauche est de 40 unités. Ces quatre distances peuvent être stockées dans une instance de la classe Insets de JavaFX, prévue pour cela, qui peut être créée ainsi :

    new Insets(10, 10, 20, 40)
    

    Tout point se trouvant à l'intérieur de ce rectangle correspondent à un couple (position, altitude) dans le monde réel. Par exemple, le point dans le coin haut-gauche du rectangle de la figure 3 correspond au couple (0 m, 663 m). Dans le système de coordonnées JavaFX du panneau contenant le rectangle, ce même point a les coordonnées (40, 10).

    Sachant qu'il est très fréquent de devoir convertir entre ces deux systèmes de coordonnées, nous vous conseillons très fortement de représenter le passage de l'un à l'autre au moyen d'une paire de valeurs de type Transform, le type JavaFX représentant ce que l'on nomme des transformation affines. Cette classe offre entre autres plusieurs variantes d'une méthode nommée transform permettant d'appliquer la transformation à un point, et plusieurs variantes d'une méthode nommée deltaTransform permettant de l'appliquer à un vecteur.

    La conversion entre les deux systèmes de coordonnées qui nous intéressent ici peut s'exprimer assez facilement en enchaînant une première translation, une mise à l'échelle, et une seconde translation.

    Pour construire un tel enchaînement au moyen des classes offertes par JavaFX, il suffit de créer une instance de Affine et d'utiliser les méthodes prependTranslation et prependScale pour y ajouter les trois transformations, dans l'ordre donné ci-dessus. (Ces transformations sont appliquées dans l'ordre inverse, raison pour laquelle nous vous conseillons d'utiliser les méthodes dont le nom commence par prepend, et pas celles dont le nom commence par append).

    Plutôt que de ne créer qu'une seule transformation, nous vous conseillons d'en créer deux, à savoir :

    1. screenToWorld, qui passe du système de coordonnées du panneau JavaFX contenant le rectangle bleu au système de coordonnées du « monde réel »,
    2. worldToScreen, qui fait le passage inverse.

    Bien entendu, la seconde transformation est simplement l'inverse de la première, et s'obtient très facilement à partir d'elle au moyen de la méthode createInverse de Transform.

    Comme expliqué ci-dessous, nous vous conseillons de stocker ces deux transformations dans des propriétés à usage interne, afin de pouvoir facilement mettre à jour l'interface lorsqu'elles changent.

  3. Propriétés à usage interne

    Afin de faciliter la mise à jour de l'interface graphique, il est recommandé de définir les propriétés suivantes, réservées à un usage interne de la classe :

    • une propriété de type ObjectProperty<Rectangle2D> contenant le rectangle englobant le dessin du profil — le rectangle bleu de la figure 3,
    • deux propriétés de type ObjectProperty<Transform> contenant les transformations screenToWorld et worldToScreen.

    L'existence de ces propriétés permet d'utiliser les mécanismes JavaFX basés sur le patron Observer — liens, auditeurs, etc. — pour mettre à jour votre interface, ce qui simplifie et clarifie le code. Par exemple, la ligne de mise en évidence peut ainsi être placée et rendue visible de manière très simple en liant, au moyen de la méthode bind, quatre de ses propriétés :

    • layoutX à une expression donnant la coordonnée x correspondant à la position à mettre en évidence, qui provient de la propriété passée au constructeur — expression qui peut être construite au moyen de la méthode createDoubleBinding de Bindings,
    • startY à la valeur de la propriété minY du rectangle englobant le dessin du profil — qui s'obtient en appliquant la méthode select de Bindings à la propriété contenant ce rectangle et à la chaîne "minY",
    • endY à la valeur de la propriété maxY du rectangle englobant le dessin du profil — qui s'obtient de manière similaire à minY,
    • visible à une expression qui n'est vraie que si la propriété contenant la position à mettre en évidence contient une valeur supérieure ou égale à 0 — qui s'obtient au moyen de la méthode greaterThanOrEqualTo.
  4. Grille

    La totalité de lignes horizontales et verticales composant la grille superposée au profil est représentée au moyen d'un seul nœud JavaFX de type Path. Chacune des lignes est composée de deux « éléments de chemin » de type PathElement :

    1. une instance de MoveTo, dont les coordonnées sont celles d'une extrémité de la ligne,
    2. une instance de LineTo, dont les coordonnées sont celles de l'autre extrémité de la ligne.

    Ces deux éléments de chemin peuvent être vus comme des instructions permettant de dessiner une ligne en deux étapes : premièrement, en se déplaçant à une des extrémités de la ligne (MoveTo) ; puis deuxièmement, en tirant un trait depuis là jusqu'à l'autre extrémité de la ligne (LineTo).

    Le choix de l'espacement à utiliser entre les lignes de la grille est fait selon la technique décrite à la §2.1. Pour faciliter votre travail, les différentes valeurs utilisables pour séparer les lignes (verticales) de position et celles (horizontales) d'altitude vous sont fournies ici sous forme de tableaux Java que vous pouvez copier dans votre classe :

    int[] POS_STEPS =
      { 1000, 2000, 5000, 10_000, 25_000, 50_000, 100_000 };
    int[] ELE_STEPS =
      { 5, 10, 20, 25, 50, 100, 200, 250, 500, 1_000 };
    

    Notez que les lignes de la grille ne doivent être placées qu'à des multiples de l'espacement choisi.

    Pour déterminer la distance séparant deux lignes de la grille, vous pouvez utiliser la méthode deltaTransform de la transformation worldToScreen. Cette méthode fonctionne de manière similaire à transform mais est destinée à transformer un vecteur plutôt qu'un point, et elle ignore donc l'éventuelle translation effectuée par la transformation.

  5. Étiquettes

    La valeur associée à chacune des lignes de la grille est donnée dans la marge du graphe, en bas pour la position, à gauche pour l'altitude.

    Les étiquettes de position doivent être placées de manière à ce que leur texte soit centré sous la ligne correspondante. Pour ce faire, l'instance de Text les représentant doit être :

    • positionnée par rapport à son sommet, en définissant la valeur de sa propriété textOrigin à VPos.TOP,
    • décalée à gauche par rapport à la position de la ligne d'une distance égale à la moitié de sa propre largeur, qui s'obtient en appelant la méthode prefWidth avec 0 en argument.

    Notez de plus que ces étiquettes sont exprimées en kilomètres, bien qu'en interne la position soit représentée en mètres.

    Les étiquettes d'altitude doivent être placées de manière à ce que leur texte soit justifié à droite et centré à côté de la ligne correspondantes. Pour ce faire, l'instance de Text les représentant doit être :

    • positionnée par rapport à son centre, en définissant la valeur de sa propriété textOrigin à VPos.CENTER,
    • décalée à gauche par rapport à la position de la ligne d'une distance égale à sa propre largeur plus 2.

    La classe de style grid_label doit être attachée à toutes les étiquettes, ainsi que la classe horizontal pour celles associées à une position, et vertical pour celles associées à une altitude.

    Pour que la méthode prefWidth retourne la bonne valeur, il est indispensable d'associer à chacune des instances de Text représentant une étiquette la bonne police de caractères, au moyen de la méthode setFont. Pour les étiquettes, nous utiliserons la police Avenir de taille 10, qui s'obtient ainsi :

    Font.font("Avenir", 10)
    
  6. Statistiques

    Les statistiques de l'itinéraire présentées dans le bas du panneau sont constituées d'un texte qui peut être facilement construit au moyen de la méthode format (ou formatted) de String. La chaîne de formatage à utiliser peut être obtenue au moyen de l'expression suivante :

    "Longueur : %.1f km" +
      "     Montée : %.0f m" +
      "     Descente : %.0f m" +
      "     Altitude : de %.0f m à %.0f m"
    
  7. Gestion des événements

    ElevationProfileManager doit détecter les mouvements de la souris au-dessus du rectangle contenant le dessin du profil afin de garder à jour le contenu de la propriété mousePositionOnProfileProperty.

    Cela implique d'installer deux gestionnaires d'événements sur le panneau contenant le profil :

    • un premier détectant les mouvements du pointeur de la souris lorsqu'elle survole ce panneau, installé au moyen de setOnMouseMoved,
    • un second détectant la sortie du pointeur de la souris du panneau, installé au moyen de setOnMouseExited.

    Pour mémoire, lorsque le pointeur de la souris ne se trouve pas dans le rectangle contenant le profil, la propriété doit contenir NaN.

3.2. Tests

Pour tester votre classe, vous pouvez utiliser le programme ci-dessous, que vous devrez probablement adapter à votre projet. Notez l'appel à bind utilisé pour lier la propriété contenant la position à mettre en évidence à celle contenant la position de la souris au-dessus du profil.

public final class Stage10Test 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"));
    CityBikeCF costFunction = new CityBikeCF(graph);
    RouteComputer routeComputer =
      new RouteComputer(graph, costFunction);

    Route route = routeComputer
      .bestRouteBetween(159049, 117669);
    ElevationProfile profile = ElevationProfileComputer
      .elevationProfile(route, 5);

    ObjectProperty<ElevationProfile> profileProperty =
      new SimpleObjectProperty<>(profile);
    DoubleProperty highlightProperty =
      new SimpleDoubleProperty(1500);

    ElevationProfileManager profileManager =
      new ElevationProfileManager(profileProperty,
				  highlightProperty);

    highlightProperty.bind(
      profileManager.mousePositionOnProfileProperty());

    Scene scene = new Scene(profileManager.pane());

    primaryStage.setMinWidth(600);
    primaryStage.setMinHeight(300);
    primaryStage.setScene(scene);
    primaryStage.show();
  }
}

En exécutant ce programme, vous devriez voir apparaître à l'écran une fenêtre ayant le même aspect que celle de la figure 1.

En déplaçant le pointeur de la souris sur le profil vous devriez constater que la ligne de mise en évidence le suit.

En redimensionnant la fenêtre, vous devriez constater que le profil est également redimensionné, et qu'à certains moments l'espacement entre les lignes de la grille change.

4. Résumé

Pour cette étape, vous devez :

  • écrire la classe ElevationProfileManager 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.