Etape 10 – Interface graphique de base

1 Introduction

Le but de cette étape est d'écrire la première version de l'interface graphique, qui permet d'afficher une carte isochrone à l'écran. A la fin de cette étape, la fenêtre principale de votre programme devrait ressembler à la copie d'écran ci-dessous.

gui-e10.png

Figure 1 : Première version de l'interface graphique

Cette interface est réalisée au moyen de la bibliothèque Swing, qu'il convient de décrire brièvement.

1.1 Swing

Swing est la bibliothèque standard pour réaliser des interfaces graphiques en Java, et elle se base sur une bibliothèque plus ancienne nommée AWT (pour Abstract Window Toolkit). Ces deux bibliothèques sont assez grosses et complexes, et leur conception laisse malheureusement parfois à désirer pour des raisons historiques.

Seules les notions de base nécessaires au projet sont présentées ici. Une bonne source d'information complémentaire est The Swing Tutorial (en anglais). Des références vers les parties pertinentes de ce texte sont données plus bas.

Composants

Une interface Swing est composée d'un certain nombre d'éléments, nommés composants (components), qui ont pour la plupart une représentation graphique et avec lesquels il est parfois possible d'interagir. Par exemple, les fenêtres, les menus, les listes déroulantes, les boutons, les champs textuels, etc. sont des composants. Dans Swing, les composants sont généralement modélisés par des sous-classes de la classe JComponent.

Swing offre un certain nombre de composants prédéfinis, comme ceux mentionnés dans le paragraphe précédent. Mais il est également possible d'en définir de nouveaux en héritant de la classe JComponent et en redéfinissant un certain nombre de méthodes.

Organisation hiérarchique

Les composants d'une interface sont organisés de manière hiérarchique, dans ce qu'on appelle en anglais la containment hierarchy. C'est-à-dire qu'il existe certains composants de base, principalement les fenêtres, qui peuvent contenir d'autres composants. Ces composants peuvent à leur tour en contenir d'autres, et ainsi de suite jusqu'aux composants terminaux.

On distingue donc généralement deux types de composants :

  1. Les conteneurs (containers), dont le but principal est de contenir d'autres composants. Généralement, leur représentation graphique est très simple voire inexistante. Les fenêtres sont un exemple de conteneur, un autre est le panneau (panel ou parfois pane), modélisé par la classe JPanel, très utilisé pour organiser les interfaces mais souvent invisible à l'écran car dénué de représentation graphique propre.
  2. Les composants terminaux, qui ne peuvent pas en contenir d'autres et qui terminent donc la hiérarchie. On les nomme parfois feuilles car ils se situent aux feuilles de l'arbre que forme la hiérarchie. Ces composants terminaux ont généralement une représentation graphique et il est parfois possible d'interagir avec eux. Les boutons sont un exemple de composant terminal, un autre est l'étiquette (label), modélisée par la classe JLabel.

Les conteneurs possèdent entre autres une méthode, add, qui permet de leur ajouter des composants fils. La position de ces composants dans le conteneur est déterminée de manière automatique par un gestionnaire de mise en page, décrit ci-dessous.

Référence : Using Top-Level Containers

Gestionnaires de mise en page

A chaque conteneur est attaché un gestionnaire de mise en page (layout manager), qui se charge de placer et de dimensionner les composants du conteneur dans celui-ci. Chaque gestionnaire de mise en page a une stratégie de placement des composants qui lui est propre. Ainsi, certains gestionnaires sont très simples et se contentent de placer les composants côte-à-côte, tandis que d'autres sont plus complexes et placent les composants de manière à satisfaire certaines contraintes.

Avec Swing, les gestionnaires de mise en page implémentent l'interface LayoutManager. Seuls deux gestionnaires de mise en page seront utilisés pour ce projet : FlowLayout et BorderLayout.

Le gestionnaire FlowLayout est l'un des gestionnaires les plus simples qui soit. Il place ses composants fils côte-à-côte et de gauche à droite.

Le gestionnaire BorderLayout découpe quant à lui l'espace du conteneur auquel il est attaché en 5 zones ayant chacune un nom, comme illustré dans la figure ci-dessous.

BorderLayout.png

Figure 2 : Découpage d'un conteneur en zones par un gestionnaire BorderLayout

Pour ajouter un composant à un conteneur dont le gestionnaire de mise en page est de type BorderLayout, on utilise une variante de la méthode add, qui prend un argument supplémentaire donnant l'identité de la zone dans laquelle le composant doit être placé. La classe BorderLayout contient des champs statiques nommés comme les zones et contenant leur identité. Par exemple, le morceau de code suivant attache un BorderLayout à un conteneur puis y place le composant child dans la zone CENTER :

JComponent container = …;
JComponent child = …;

container.setLayout(new BorderLayout());
container.add(child, BorderLayout.CENTER);

Chacune des cinq zones peut contenir au plus un composant. Toutes les zones sont dimensionnées de manière à être assez grande pour le composant qu'elles contiennent, et les zones vides ont donc une taille nulle. S'il reste de la place dans le conteneur, la zone CENTER est agrandie jusqu'à ce que tout l'espace du conteneur soit occupé.

Références : A Visual Guide to Layout Managers, Using Layout Managers, How to use FlowLayout et How to Use BorderLayout.

2 Mise en œuvre Java

La mise en œuvre Java de cette étape consiste à d'abord écrire la classe TileMapComponent, un composant Swing capable d'afficher une carte en tuiles, et ensuite à compléter le squelette de la classe IsochroneTL que nous vous fournissons.

2.1 Classe TiledMapComponent

La classe TiledMapComponent du paquetage ch.epfl.isochrone.gui, publique et finale, est un composant Swing capable d'afficher une carte en tuiles, ces dernières étant fournies par un ou plusieurs fournisseurs de tuiles.

Ce composant possède deux attributs modifiables :

  1. le niveau de zoom de la carte à afficher, compris entre 10 et 19 (inclus) et dont la valeur initiale est spécifiée lors de la construction,
  2. la liste des fournisseurs fournissant les tuiles de la carte, initialement vide.

Lorsque la liste des fournisseurs contient plus d'un élément, les tuiles des différents fournisseurs sont affichées l'une par-dessus l'autre, dans l'ordre des founisseurs. Le premier fournisseur fournit donc le fond de carte, et ses tuiles sont généralement opaques, tandis que les suivants fournissent des couches à superposer au fond de carte, et leurs tuiles sont généralement partiellement transparentes.

Comme tout composant Swing, TiledMapComponent est une sous-classe de JComponent. Plusieurs des nombreuses méthodes de cette classe peuvent (ou doivent) être redéfinies pour mettre en œuvre le comportement spécifique du composant. Pour TiledMapComponent, les deux méthodes à redéfinir sont :

  • Dimension getPreferredSize(), qui retourne la taille « idéale » du composant. Ici, cette taille est simplement celle de la carte du monde au niveau de zoom courant, comme expliqué plus bas.
  • void paintComponent(Graphics g0), qui est appelée par Swing chaque fois que le composant doit être redessiné, p.ex. suite à un redimensionnement. L'argument de cette méthode est le contexte graphique, grâce auquel il est possible d'effectuer le dessin. Pour des raisons historiques, cet argument est déclaré comme ayant le type Graphics, mais en réalité la valeur passée est toujours de type Graphics2D, type qui offre beaucoup plus de méthodes que Graphics. Une méthode paintComponent typique commence donc généralement par faire un transtypage de cette valeur vers le type Graphics2D.

Le système de coordonnées de TiledMapComponent est simplement le système de coordonnées OSM au niveau de zoom courant. Comme cela a été expliqué dans l'énoncé de l'étape 1, la carte du monde au niveau de zoom \(z\) est un carré de \(2^{z+8}\) pixels de côté. Contrairement à ce que l'on pourrait penser, créer un composant aussi grand ne pose pas de problème, car les parties invisibles des composants ne sont jamais dessinées, et aucune mémoire ne leur est allouée. C'est heureux, car au niveau de zoom 19, l'image de la carte du monde nécessite plus de 72 péta-octets de mémoire, un million de fois plus que la quantité dont disposent les ordinateurs actuels.

Lorsque la méthode paintComponent est appelée, elle a la possibilité (et ici, l'obligation !) de déterminer la partie du composant qui est visible, et ne dessiner que cette partie. Pour ce faire, il suffit d'appeler la méthode getVisibleRect, qui retourne le rectangle visible du composant, dans son propre système de coordonnées. Il est facile d'utiliser ce rectangle pour déterminer l'ensemble des tuiles visibles, et de n'obtenir que celles-ci des fournisseurs de tuiles.

A noter que lorsque le niveau de zoom ou les fournisseurs de tuiles changent, il faut provoquer un redessin du composant. Pour ce faire, un simple appel à la méthode repaint du composant suffit.

2.2 Classe IsochroneTL

La classe IsochroneTL du paquetage ch.epfl.isochrone.gui est la classe principale du programme. Elle se charge de construire l'interface graphique et d'afficher la fenêtre du programme à l'écran, avec laquelle l'utilisateur peut ensuite interagir.

Etant donné que la construction de l'interface graphique demande de bonnes connaissances Swing, un squelette de la classe IsochroneTL vous est exceptionnellement fourni. Ce squelette se trouve dans une archive Zip — à importer en suivant la procédure habituelle — contenant également une image du logo des tl, qui doit apparaître dans l'interface.

Une fois la classe TiledMapComponent écrite, vous devriez pouvoir exécuter le squelette de la classe IsochroneTL fourni et une fenêtre semblable à celle de la figure ci-dessous devrait s'afficher à l'écran. Notez que le squelette fourni ne passe aucun fournisseur de tuiles au composant TiledMapComponent, raison pour laquelle aucune carte n'est affichée à l'écran. Cela dit, le constructeur de la classe IsochroneTL stocke dans la variable bgTileProvider un fournisseur pour le fond de carte, et il devrait donc être trivial de modifier le code pour le passer au composant.

gui-e10_skeleton.png

Figure 3 : Squelette d'interface

Pour cette étape, le squelette fourni doit être complété en trois étapes :

  1. le constructeur doit être terminé afin de créer un fournisseur de tuile pour une carte isochrone dont les paramètres sont ceux donnés par les variables privées et statiques de la classe (p.ex. la date doit être INITIAL_DATE, l'heure de départ INITIAL_DEPARTURE_TIME, etc.), le transformer pour rendre ses tuiles transparentes à 50% et finalement le passer, accompagné du fournisseur pour le fond de carte, au composant d'affichage de la carte,
  2. la méthode createCenterPanel doit être complétée afin de permettre le déplacement de la carte au moyen de la souris,
  3. la méthode createCenterPanel doit être complétée afin de permettre le zoom dans la carte au moyen de la molette de la souris.

Une fois la première étape terminée, la fenêtre de votre programme devrait ressembler à celle donné dans l'introduction. Lorsque les deux étapes suivantes seront terminées, il devrait être possible d'intéragir avec cette carte exactement comme avec les cartes de sites comme OpenStreetMap ou Google Maps.

La première étape ne sera pas décrite plus en détail, mais les deux dernières le sont ci-dessous, après une description de la hiérarchie des conteneurs de cette étape.

Hiérarchie des conteneurs

L'image ci-dessous illustre la hiérarchie des conteneurs construite par la classe IsochroneTL fournie. Les conteneurs fils sont généralement placés au-dessus de leur conteneur parent, sauf dans un cas : les deux composants du sommet (copyrightPanel et viewPort) sont en fait les deux des fils du même composant layeredPane, mais ils sont superposés, comme expliqué plus bas.

container_hierarchy_e10.png

Figure 4 : Hiérarchie des conteneurs pour cette étape

Dans cette image, la classe de chaque composant est indiquée sur celui-ci, et la classe de son éventuel gestionnaire de mise en page est indiqué en-dessous, entre parenthèses. Les noms des variables du code contenant les différents conteneurs sont quant à eux donnés sur le côté.

A la base de la hiérarchie se trouve la fenêtre principale du programme (frame). Cette fenêtre possède un panneau, nommé panneau de contenu (content pane), créé automatiquement par Swing, raison pour laquelle il est grisé sur le dessin. Ce panneau peut s'obtenir au moyen de la méthode getContentPane de la fenêtre principale.

Le panneau de contenu a un gestionnaire de mise en page de type BorderLayout dont la zone centrale (CENTER) est occupée par le panneau central (centerPanel). C'est ce panneau qui contient les composants affichant la carte et le texte de copyright.

L'unique fils du panneau central est un panneau en couches de type JLayeredPane (layeredPane). Un tel panneau superpose ses fils au lieu de les placer côte-à-côte comme les autres panneaux. Il est utile ici pour superposer le texte de copyright à la carte, de manière à ce que cette dernière apparaisse en transparence derrière ce premier.

Le panneau en couches a donc deux fils :

  1. le panneau de copyright, qui ne contient rien d'autre qu'une étiquette (instance de JLabel) composé du logo des tl et du texte de copyright, et
  2. l'aire d'affichage de la carte, de type JViewPort, qui montre une (petite) portion rectangulaire du composant affichant la carte, de type TiledMapComponent.

Comme cela a été expliqué plus haut, le composant affichant la carte est (potentiellement) très grand. Pour cette raison, il n'est pas directement placé dans la hiérarchie comme fils du panneau en couches. Au lieu de cela, il est placé dans ce que l'on nomme une aire d'affichage (viewport en anglais), modélisée par la classe JViewPort de Swing.

Le composant JViewPort référence un autre composant, appelé sa vue (view), qui lui fournit le contenu à afficher à l'écran. Toutefois, étant donné que la vue est généralement (beaucoup) plus grande que le contenu affiché (et affichable) à l'écran, seule une portion rectangulaire de celle-ci est montrée dans l'aire d'affichage. La taille de cette portion est égale à celle de l'aire d'affichage, et sa position dans la vue est déterminée par un attribut de l'aire d'affichage (c-à-d une instance de JViewPort). En modifiant la position de cette vue, on peut faire défiler la vue dans l'aire d'affichage, et permettre ainsi à l'utilisateur de voir, morceau par morceau, les parties de la vue qui l'intéressent. Tout programme affichant une vue de taille importante et permettant de s'y déplacer par scrolling — p.ex. un traitement de texte, un tableur ou un visualiseur de cartes — est ainsi basé sur cette notion d'aire d'affichage.

L'image ci-dessous illustre le rapport entre la vue, ici le composant de type TiledMapComponent qui affiche (potentiellement) la carte du monde entier, et l'aire d'affichage, ici le composant Swing JViewPort qui ne montre qu'une petite portion de cette vue.

viewport.png

Figure 5 : Relation entre JViewPort et TiledMapComponent

Déplacement

Une fois que la carte est affichée avec succès à l'écran, il convient de donner à l'utilisateur la possibilité de déplacer — au moyen de la souris — la partie de la carte affichée dans l'aire d'affichage. Ainsi, lorsque l'utilisateur maintient pressé le bouton gauche de la souris tout en la déplaçant, le point de la carte qui se trouve sous le pointeur doit suivre celui-ci.

Pour réagir à certains événements, comme la pression d'un bouton de la souris ou son déplacement, Swing fournit la notion d'auditeur (listener), très proche de la notion d'observateur du patron Observer. Un auditeur est un objet implémentant une certaine interface que l'on attache à un composant et dont les méthodes sont appelées lorsque certains événements se produisent sur ce composant.

Par exemple, pour réagir au clic du bouton gauche de la souris effectué à une position quelconque sur le composant layeredPane, il est possible d'ajouter le code suivant à la fin de la méthode createCenterPanel :

layeredPane.addMouseListener(new MouseAdapter() {
        @Override
        public void mousePressed(MouseEvent e) {
            System.out.println("bouton pressé à la position "
                               + e.getLocationOnScreen());
        }
    });

Chaque fois que le bouton gauche de la souris est pressé alors que celle-ci se trouve à l'intérieur du composant layeredPane, la méthode mousePressed de l'auditeur (anonyme) ci-dessus est appelée et reçoit en argument un objet MouseEvent décrivant l'événement qui s'est produit. Ainsi, il est possible de connaître la position du pointeur de la souris dans le système de coordonnées de l'écran en appelant la méthode getLocationOnScreen comme illustré ci-dessus.

Pour mettre en œuvre le déplacement de la carte, l'idée est d'ajouter deux auditeurs au composant layeredPane.

Le premier détecte les pressions du bouton gauche de la souris et mémorise à ce moment deux informations : la position de la souris, obtenue p.ex. au moyen de la méthode getLocationOnScreen de l'événement, et la position de la carte dans l'aire d'affichage, obtenue au moyen de la méthode getViewPosition de JViewPort.

Le second auditeur détecte quant à lui les déplacements de la souris avec le bouton gauche pressé et, à chacun d'entre-eux, il ajuste la position de la carte dans l'aire d'affichage, au moyen de la méthode setViewPosition de JViewPort, afin que la carte suive la souris.

Les deux auditeurs sont des sous-classes anonymes de MouseAdapter. Le premier redéfinit la méthode mousePressed et est ajouté au composant layeredPane au moyen de sa méthode addMouseListener, comme illustré plus haut. Le second redéfinit la méthode mouseDragged et est ajouté au composant layeredPane au moyen de la méthode addMouseMotionListener (attention, ce n'est pas la même méthode que pour l'auditeur précédent !).

Zoom

En plus de la possibilité de déplacer la carte, il faut également fournir à l'utilisateur la possibilité de changer l'échelle de celle-ci. Cela se fait au moyen de la molette de la souris, de manière à ce que le point situé sous le pointeur ne bouge pas. Par exemple, si l'utilisateur visualise une carte montrant le Learning Center de l'EPFL et place le pointeur sur le coin sud ouest du bâtiment avant d'actionner la molette pour changer d'échelle, le coin sud ouest du Learning Center doit toujours rester sous le pointeur de la souris.

Pour détecter les mouvements de la molette de la souris, Swing permet l'ajout d'un auditeur de type MouseWheelListener à un composant, via sa méthode addMouseWheelListener. La méthode mouseWheelMoved de cet auditeur reçoit en argument un événement de type MouseWheelEvent dont la méthode getWheelRotation permet de connaître le nombre de crans dont la molette a été tournée. Le signe de cette valeur donne le sens de rotation.

A chaque cran doit correspondre un niveau de zoom, et un nombre de crans positif doit provoquer une diminution du niveau de zoom, et inversément. Ainsi, si le niveau de zoom actuel est 15 et getWheelRotation retourne -2, le nouveau niveau de zoom doit être 17.

Pour garantir que le point sous le pointeur de souris ne bouge pas lors du changement d'échelle, il faut s'assurer que la position de ce point dans l'aire d'affichage ne change pas. Pour ce faire, les méthodes suivantes sont utiles :

  • getPoint de MouseWheelEvent, qui permet d'obtenir la position du pointeur de la souris au moment où la molette a été tournée, dans le système de coordonnées du composant (ici layeredPane, étant donné que c'est celui auquel l'auditeur est attaché),
  • convertPoint de SwingUtilities (méthode statique), qui permet de convertir un point entre les systèmes de coordonnées de deux composants.

2.3 Test

Il est très difficile de tester le résultat de cette étape automatiquement, mais relativement simple de vérifier visuellement que la carte affichée est similaire à celle donnée en exemple plus haut, et que le déplacement et le changement d'échelle se comportent correctement.

3 Résumé

Pour cette étape, vous devez :

  1. Ecrire la classes TiledMapComponent et compléter la classe IsochroneTL fournie selon les spécifications ci-dessus.
  2. Tester votre programme.
  3. Documenter la totalité des entités publiques que vous avez définies.