Interface graphique (II)
Etape 11

1 Introduction

Le but de cette étape, la dernière de la partie obligatoire du projet, est de terminer l'interface graphique et, du même coup, le programme Alpano.

1.1 Interface graphique

L'interface graphique a déjà été présentée à l'étape précédente, mais ses trois composantes principales sont rappelées dans la figure 1 ci-dessous :

  1. la partie supérieure montrant l'image du panorama et les étiquettes,
  2. la partie inférieure gauche montrant les paramètres et permettant leur modification,
  3. la partie inférieure droite montrant des informations liées au point du panorama actuellement situé sous le pointeur de la souris.

Le comportement général de ces trois parties a déjà été décrit, raison pour laquelle seule leur mise en œuvre Java est détaillée dans ce qui suit.

gui-labeled.png

Figure 1 : Les trois parties principales de l'interface graphique d'Alpano

2 Mise en œuvre Java

La mise en œuvre de l'interface graphique se fait dans une unique classe contenant le programme principal, placée comme d'habitude dans le paquetage ch.epfl.alpano.gui.

2.1 Classe Alpano

La classe Alpano contient le programme principal du projet. Elle se charge de construire l'interface graphique, puis laisse le contrôle à JavaFX, qui gère les événements et informe les différents auditeurs attachés à l'interface jusqu'à ce que l'utilisateur quitte l'application.

Comme toute classe représentant le programme principal d'une application JavaFX, la classe Alpano doit hériter de la classe Application et son code principal doit se trouver dans la redéfinition de la méthode start, ainsi :

import javafx.application.Application;

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

  @Override
  public void start(Stage primaryStage) {
    // … création de l'interface graphique

    BorderPane root = …;
    Scene scene = new Scene(root);

    primaryStage.setTitle("Alpano");
    primaryStage.setScene(scene);
    primaryStage.show();
  }
}

Au démarrage du programme, la liste des sommets est lue depuis le fichier nommé alps.txt, de même que les huit fichiers HGT couvrant la zone allant de 6° à 10° de longitude et de 45° à 47° de latitude, qui sont combinés en un unique MNT discret. Ces huit fichiers HGT suffisent au dessin de tous les panoramas prédéfinis, et ne charger qu'eux permet de ne pas inutilement ralentir l'exécution du programme.

Attention : aussi bien le fichier contenant les sommets que les fichiers HGT doivent être lus depuis le répertoire courant. C'est-à-dire que les instances de File leur correspondant ne doivent contenir que le nom du fichier, sans aucun chemin.

Par exemple, les sommets doivent être lus du fichier créé par l'appel
new File("alps.txt"), le premier fichier HGT par l'appel
new File("N45E006.hgt"), et ainsi de suite.

Si vous ne respectez pas ces consignes, votre programme ne fonctionnera pas durant nos tests et vous perdrez bêtement tous les points liés aux tests fonctionnels.

Une fois les fichiers chargés, l'interface graphique est construite de la manière décrite plus bas. Les paramètres qui se trouvent initialement dans l'interface sont ceux du panorama prédéfini nommé Alpes du Jura, présenté à l'étape 8. Notez que ces paramètres ne sont chargés que dans le bean des paramètres, pas dans celui chargé du calcul.

2.1.1 Construction de l'interface graphique

Le code de construction de l'interface graphique est relativement long et mérite donc d'être judicieusement découpé en plusieurs méthodes, chacune chargée d'une partie importante de cette interface.

Le graphe de scène correspondant à l'interface complète est présenté dans la figure ci-dessous. A la plupart des flèches liant un nœud parent à l'un de ses fils est associé un nom qui donne une idée du contenu de ce dernier. Les parties entourées par une ligne traitillée correspondent aux parties principales de l'interface graphique présentées dans la figure 1.

Sorry, your browser does not support SVG.

Figure 2 : Graphe de scène d'Alpano

Le nœud nommé « … » et situé en bas à gauche signifie que le panneau labelsPane — qui contient les étiquettes et lignes du panorama — possède un nombre variable de fils, un pour chaque ligne (de type Line) et un pour chaque étiquette (de type Text).

Le second nœud nommé « … » et situé vers le haut à droite signifie que le panneau paramsGrid — qui contient les composants graphiques présentant les paramètres du panorama — possède un grand nombre de fils, la plupart de type Label et TextField. Notez que parmi ces fils figure aussi la zone textuelle contenant les informations au sujet du point du panorama situé sous le pointeur de la souris.

Comme cette figure le montre, à la racine du graphe de scène se trouve un panneau de type BorderPane. La partie centrale de ce panneau (Center) est occupée par l'image du panorama (sous-arbre entouré de gauche, portant le numéro 1) tandis que sa partie inférieure (Bottom) est occupée par le panneau des paramètres et des informations (sous-arbre entouré de droite, portant les numéros 2 et 3).

2.1.2 Image du panorama

L'image du panorama est placée dans un composant ImageView, nommé panoView dans la figure 2. Ce composant se charge de l'affichage de l'image et de son éventuel redimensionnement.

La taille de l'image du panorama affichée à l'écran est celle donnée par les paramètres du panorama, sans prendre en compte le suréchantillonnage. Par contre, la taille de l'image du panorama calculée tient compte du suréchantillonnage. Dès lors, si le degré de suréchantillonnage n'est pas nul, l'image calculée doit être redimensionnée avant d'être affichée. La responsabilité de ce redimensionnement est laissée au composant ImageView, d'une part car cela est plus simple et d'autre part car cela produit une image de meilleure qualité sur les écrans à haute résolution (appelés « Retina » par Apple, « HiDPI » par d'autres fabricants).

Pour garantir ce redimensionnement, la propriété fitWidth du composant ImageView doit toujours être égale à la largeur du panorama. Il est très simple de s'en assurer, en liant simplement la propriété fitWidth du composant ImageView à la propriété width du bean des paramètres. De la même manière, il est très simple de s'assurer que l'image affichée par le composant est toujours celle du panorama en liant sa propriété image à celle du même nom du bean calculateur (computer bean).

Etant donné que le redimensionnement ne doit pas déformer l'image du panorama, la propriété preserveRatio du composant ImageView doit être mise à vrai. Sa propriété smooth doit également l'être, pour garantir un redimensionnement de bonne qualité.

Lors du survol du panorama par la souris, des informations sur le point se trouvant sous le pointeur sont affichées dans le panneau d'information. Pour ce faire, un auditeur des mouvements de la souris est attaché au composant ImageView, via la méthode setOnMouseMoved. Cet auditeur convertit la position du pointeur de la souris en le point du panorama correspondant, extrait les données qui y sont associées (position, distance, etc.) et fait en sorte qu'elles soient affichées selon le format suivant :

Position : 45.9380°N 7.2986°E
Distance : 124.8 km
Altitude : 4267 m
Azimut : 162.3° (S)	Elévation : 0.8°

Cet exemple a été obtenu en plaçant le pointeur de la souris proche du sommet du Grand Combin, dans le panorama des Alpes vues du Jura. Notez qu'après la valeur de l'azimut en degré figure l'octant correspondant, sous forme textuelle, entre parenthèses.

Lors du clic sur un point du panorama, une carte OpenStreetMap centrée sur ce point et dotée d'un marqueur est ouverte dans le navigateur de l'utilisateur. Pour ce faire, un auditeur des clics de la souris est attaché au composant ImageView, via la méthode setOnMouseClicked.

L'URL désignant une carte OpenStreetMap centrée (et marquée) en un point donné a la forme suivante :

http://www.openstreetmap.org/?mlat=ϕ&mlon=λ#map=15/ϕ/λ

où ϕ est la latitude du point en degrés, λ sa longitude en degrés et 15 est le niveau de zoom.

Notez que le séparateur décimal à utiliser pour la longitude et la latitude est le point, pas la virgule. Dès lors, si vous utilisez la méthode format de String pour construire les composantes de l'URL, prenez garde au fait que par défaut elle tient compte des conventions locales de la langue du système pour choisir le séparateur décimal, comme l'illustre l'extrait de programme suivant :

// Dangereux : "3.14" ou "3,14" en fonction de la langue
String s1 = String.format("%.2f", Math.PI);

// Sûr : toujours "3.14"
String s2 = String.format((Locale)null, "%.2f", Math.PI);

L'URL elle-même est représentée par une instance de la classe URI, qui peut être ouverte dans le navigateur de l'utilisateur au moyen de la méthode browse, comme l'extrait de programme suivant l'illustre :

String qy = …;  // "query" : partie après le ?
String fg = …;  // "fragment" : partie après le #
URI osmURI =
  new URI("http", "www.openstreetmap.org", "/", qy, fg);
java.awt.Desktop.getDesktop().browse(osmURI);

2.1.3 Etiquettes du panorama

Les étiquettes du panorama, produites par la classe Labelizer décrite à l'étape 9, sont les fils d'un panneau de type Pane et nommé labelsPane dans la figure 2.

Tout comme la propriété fitWidth du composant ImageView doit toujours être égale à la largeur du panorama, la propriété prefWidth du panneau des étiquettes doit toujours être égale à la largeur du panorama. De même, sa propriété prefHeight doit toujours être égale à la hauteur du panorama. Bien entendu, cette égalité est garantie au moyen de liens entre ces propriétés.

Finalement, les enfants du panneau des étiquettes sont liés à la liste des étiquettes du bean calculateur. Cela peut se faire facilement au moyen de la méthode bindContent de la classe Bindings, car la liste des étiquettes du bean calculateur est une liste observable.

2.1.4 Notice de mise à jour

La notice de mise à jour est un panneau placé au-dessus de celui montrant l'image étiquetée du panorama, et qui n'est visible que lorsque les paramètres figurant en bas de l'interface ne correspondent pas à ceux de l'image affichée en haut. Ce panneau, partiellement transparent, affiche en gros caractères le texte « Les paramètres du panorama ont changé. Cliquez ici pour mettre le dessin à jour. »

L'animation ci-dessous illustre l'apparition et la disparition de ce panneau suite à un changement de paramètre, ici le degré de suréchantillonnage. L'image du panorama a été dessinée avec un degré de 1 (2×), et dès que l'utilisateur change le degré pour le faire passer à 2 (4×), la notice de mise à jour recouvre le panorama. Bien entendu, si le degré est remis à sa valeur originale, le panneau disparaît, sans recalcul du panorama.

update-notice-anim.gif

Figure 3 : Apparition et disparition de la notice de mise à jour

Comme le message figurant sur le panneau l'indique, il est également possible de cliquer sur le panneau lui-même afin de provoquer le recalcul du panorama en utilisant les nouveaux paramètres. Une fois ce recalcul terminé, le panneau disparaît automatiquement, puisque l'égalité entre les paramètres du panorama et ceux affichés dans le bas de la fenêtre a été rétablie.

Le panneau de mise à jour, nommé updateNotice dans la figure 2, a le type StackPane, ce qui est nécessaire pour que le texte qu'il contient soit centré. Son arrière plan, qui lui est attribué au moyen de la méthode setBackground, est simplement un blanc pur d'une opacité de 90%. Le texte lui-même est une instance de Text dont la police est celle par défaut mais avec une taille de 40 points. Cette police s'obtient au moyen d'un des constructeurs de la classe Font et est attribuée au texte au moyen de la méthode setFont. Finalement, le texte est centré via un appel à la méthode setTextAlignment.

La visibilité conditionnelle du panneau de mise à jour s'obtient très facilement en liant sa propriété visible à l'inégalité des propriétés parameters du bean calculateur et du bean des paramètres. Cette dernière s'obtient quant à elle avec la méthode isNotEqualTo, dont l'exemple ci-dessous illustre l'utilisation :

ObjectProperty<String> p1 =
  new SimpleObjectProperty<>("a");
ObjectProperty<String> p2 =
  new SimpleObjectProperty<>("b");
BooleanExpression p1NotEqP2 = p1.isNotEqualTo(p2);
System.out.println(p1NotEqP2.get()); // affiche "true"
p2.set("a");
System.out.println(p1NotEqP2.get()); // affiche "false"
p1.set("b");
System.out.println(p1NotEqP2.get()); // affiche "true"

Finalement, un clic sur le panneau de mise à jour doit provoquer la copie des paramètres stockés dans le bean des paramètres dans le bean calculateur. Cette copie provoque à son tour le recalcul du panorama et donc, lorsque celui-ci est terminé, la disparition du panneau de mise à jour.

2.1.5 Combinaison de l'image, des étiquettes et de la notice

Le panneau contenant les étiquettes du panorama est placé au-dessus de celui contenant l'image du panorama, au moyen d'un composant StackPane, comme l'illustre la figure 2.

Attention : pour que le composant contenant l'image du panorama reçoive les événements de la souris, il faut absolument rendre le panneau contenant les étiquettes transparent à la souris, au moyen de setMouseTransparent.

L'empilement de l'image du panorama et de ses étiquettes est ensuite placé dans un composant ScrollPane, afin qu'il soit possible de visualiser un panorama plus grand que l'écran par défilement (scrolling).

Finalement, le panneau contenant la notice de mise à jour est placé au-dessus de celui contenant l'image étiquetée du panorama.

2.1.6 Paramètres et panneau d'information

Le panneau contenant les paramètres, paramsGrid dans la figure 2, est de type GridPane. Ce type de panneau arrange ses fils sur une grille, ce qui est très utile pour aligner plusieurs éléments d'une interface graphique. La figure ci-dessous montre l'aspect final du panneau des paramètres — sans le panneau d'information — et l'arrangement des éléments en grille y est bien visible.

gui-parameters.png

Figure 4 : Panneau des paramètres

A chacun des paramètres du panneau correspond deux composants graphiques :

  1. une étiquette, de type Label, nommant le paramètre, p.ex. Latitude (°),
  2. un composant montrant la valeur actuelle du paramètre et permettant son édition.

A l'exception du composant correspondant au suréchantillonnage, tous les composants contenant les paramètres sont des champs textuels de type TextField.

Etant donné que la valeur de chacun des paramètres est un entier, mais que les champs textuels ne peuvent contenir que des chaînes, il faut spécifier comment la transformation de l'un à l'autre peut se faire. Pour les champs textuels, cela est fait en attachant au champ un formatteur textuel basé sur un convertisseur de chaîne. L'extrait de code ci-dessous illustre cela :

TextField field = new TextField();
StringConverter<Integer> stringConverter = …;
TextFormatter<Integer> formatter =
  new TextFormatter<>(stringConverter);
field.setTextFormatter(formatter);

Les convertisseurs de chaîne à utiliser ici sont ceux écrits à l'étape 9 ainsi que IntegerStringConverter, en fonction des besoins.

Le formatteur attaché à un champ textuel (formatter ci-dessus) possède une propriété nommée value et contenant la valeur du champ avant sa conversion en chaîne. Ici, cette propriété contient donc un entier et peut être directement liée, de manière bidirectionnelle, au paramètre correspondant du bean des paramètres. Cela signifie que les changements dans le formatteur sont transmis au bean, et vice versa.

Les champs textuels sont configurés de manière à aligner leur contenu à droite (via setAlignment) et à avoir un nombre de colonnes préféré (via setPrefColumnCount) qui dépend du champ et vaut : 7 pour la latitude et longitude de l'observateur, 4 pour l'altitude, la largeur et la hauteur, et 3 pour l'azimut, l'angle de vue et la visibilité.

Le composant permettant de régler le degré de suréchantillonnage est le seul à ne pas être un champ textuel. Il s'agit d'un menu déroulant de type ChoiceBox proposant les choix 0, 1 et 2. Pour que ceux-ci soit plus faciles à interpréter pour l'utilisateur, un convertisseur de chaîne est également attaché, via la méthode setConverter, au menu. Il s'agit dans ce cas d'un convertisseur de type LabeledListStringConverter, écrit à l'étape 9 et convertissant l'entier 0 en la chaîne non, l'entier 1 en la chaîne et l'entier 2 en la chaîne .

En plus des paramètres eux-même, le panneau des paramètres contient aussi la zone textuelle dans laquelle les informations liées au point sous le pointeur de la souris apparaissent. Cette zone textuelle, de type TextArea, n'est pas éditable (propriété editable) et son nombre de lignes préféré (propriété prefRowCount) vaut 2.

La totalité des nœuds décrits plus haut, et qui constituent les fils du panneau paramsGrid, sont organisés en grille sur trois lignes de sept colonnes chacune. Chaque label ou composant occupe exactement une case de cette grille, sauf le panneau des informations qui occupe toutes les cases de la dernière colonne. Pour ajouter ces fils au panneau, il est conseillé d'utiliser les méthodes addRow et add de la classe GridPane, qui facilitent la tâche.

2.2 Tests

Pour cette dernière étape, nous vous fournissons à nouveau un fichier de vérification de signature, qui vous permet simplement de vous assurer que la classe principale du programme porte le bon nom. Ce fichier vous est comme d'habitude fourni dans une archive Zip à importer dans votre projet.

De plus, nous vous fournissons une copie d'écran de l'interface finale, panorama inclu. Les paramètres de ce panorama sont ceux indiqués dans le bas de l'image, et sont une variation mineure de ceux du panorama Alpes du Jura.

alpano-final-gui.png

Figure 5 : Interface graphique finale (cliquez pour agrandir)

3 Résumé

Pour cette étape, vous devez :

  • écrire la classe Alpano en fonction des indications données plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies,
  • rendre votre projet dans le cadre du rendu final anticipé, si vous désirez tenter d'obtenir un bonus, ou dans le cadre du rendu final normal sinon ; les instructions concernant ces rendus seront publiées ultérieurement.