Étiquetage du panorama
Etape 9

1 Introduction

Le but principal de cette étape est d'écrire le code permettant d'étiqueter le panorama, c'est-à-dire de superposer à son image les noms des principaux sommets visibles.

De plus, cette étape a aussi pour but d'écrire deux classes permettant de convertir des entiers en chaînes de deux manières différentes. Ces classes seront utilisées plus tard dans l'interface graphique.

1.1 Etiquetage du panorama

L'étiquetage du panorama se fait en deux phases successives : tout d'abord, l'ensemble des sommets visibles est déterminé ; ensuite, un sous-ensemble d'entre eux est étiqueté, en fonction de la place disponible pour les étiquettes.

Le placement des étiquettes est un problème non trivial si l'on désire maximiser leur nombre. Pour ce projet, nous avons choisi une solution relativement simple, consistant à tourner les étiquettes de -60° et à les aligner sur une ligne horizontale.

L'image ci-dessous montre un exemple de panorama étiqueté de cette manière. Comme on le voit, chaque étiquette est accompagnée d'une ligne verticale la reliant au sommet qui lui correspond.

pano-labels.png

Figure 1 : Extrait de panorama étiqueté

Notez que même si le panorama présenté ci-dessus est celui de l'introduction, les étiquettes incluent ici l'altitude des sommets et sont alignées à une position verticale différente, en raison de changements récents dans la technique d'étiquetage.

1.1.1 Visibilité des sommets

Un sommet est visible sur un panorama s'il satisfait les deux conditions suivantes :

  1. il est dans la zone visible du panorama, c-à-d dans les limites déterminées par l'azimut central, l'angle de vue horizontal, l'angle de vue vertical et la distance de visibilité maximale,
  2. un rayon partant de l'observateur et dirigé vers le sommet n'atteint pas le terrain avant d'avoir parcouru au moins la distance horizontale séparant l'observateur du sommet, à une tolérance de 200 m près.

La vérification de la seconde condition se fait à l'aide d'un profil altimétrique allant de l'observateur au sommet. La figure 2 ci-dessous montre un tel profil, ainsi que le rayon lancé pour déterminer la visibilité du sommet représenté par le cercle rouge.

La zone grisée, dont la taille a été exagérée, est celle dans laquelle le rayon doit atteindre le terrain pour que le sommet soit considéré visible. Elle commence à une distance (horizontale) de 200 m avant le sommet et s'étend à l'infini à la droite du profil. Cela signifie que si le rayon n'atteint pas le terrain dans les limites du profil, le sommet est néanmoins considéré comme visible.

Sorry, your browser does not support SVG.

Figure 2 : Détermination de visibilité par lancer de rayon

Notez que le fait que le sommet soit situé à l'extrémité droite du profil n'est pas un hasard: tous les profils utilisés pour déterminer la visibilité d'un sommet donné s'arrêtent à la distance (horizontale) lui correspondant.

1.1.2 Etiquetage des sommets

Il n'est généralement pas possible d'étiqueter tous les sommets visibles, pour deux raisons :

  1. certains sommets sont très haut sur l'image du panorama et ne laissent pas de place à leur étiquette,
  2. un étiquetage systématique entraîne souvent le chevauchement d'étiquettes.

Pour tenir compte du premier problème, seuls les sommets situés, sur l'image, au-delà d'une limite verticale donnée et fixée ici à 170 pixels sont (potentiellement) étiquetés.

Pour tenir compte du second problème, les sommets visibles sont parcourus dans l'ordre de leur position verticale sur l'image, en ordre croissant (c-à-d de haut en bas de l'image). Lors de ce parcours, un sommet n'est étiqueté que si son étiquette n'en chevauche pas une autre ajoutée précédemment.

Cet ordre de parcours étiquette en priorité les sommets situés dans le haut de l'image. D'autres ordres seraient bien entendu possibles, mais celui-ci favorise les sommets à l'horizon, qui sont généralement les mieux visibles et donc les plus intéressants pour l'utilisateur.

La ligne horizontale sur laquelle les étiquettes sont alignées est placée exactement 22 pixels au-dessus du plus haut (sur l'image, pas en altitude !) sommet étiqueté. Cette stratégie place les étiquettes aussi bas que possible sur l'image — évitant ainsi, autant que faire se peut, qu'elles ne soient tronquées — tout en laissant un espace de 22 pixels au minimum entre les sommets étiquetés et leur étiquette — évitant de trop charger l'image.

La valeur de 22 pixels a été choisie pour qu'il soit possible de laisser un espace de 2 pixels entre une étiquette et la ligne correspondant, et que la ligne la plus courte fasse 20 pixels de long.

La figure 3 ci-dessous, montrant l'image d'un panorama — et non plus un profil altimétrique — illustre ces conventions. La zone située en deça de la limite de 170 pixels est grisée, et les deux sommets s'y trouvant ne sont donc pas étiquetés. La position verticale du sommet restant, \(y_m\) sur l'image, est celle du plus haut sommet étiqueté. La position verticale d'alignement des étiquettes, \(y_l\) sur l'image, est donc donnée par : \(y_l = y_m - 22\).

Sorry, your browser does not support SVG.

Figure 3 : Etiquetage (ou non) des trois sommets d'un panorama

Finalement, pour éviter que les étiquettes ne soient trop proche des bords ou ne se chevauchent, un sommet n'est étiqueté que si :

  1. il se trouve à au moins 20 pixels des bords du panorama,
  2. aucun autre sommet déjà étiqueté ne se trouve à moins de 20 pixels à sa droite.

Notez que les différentes distances spécifiées ci-dessus (limite de 170 pixels, espacement de 20 pixels, etc.) ont été déterminées empiriquement et conviennent bien à la police de caractères utilisée par défaut par JavaFX. Idéalement, ces constantes devraient être calculées en fonction de la police utilisée pour les étiquettes, mais dans un soucis de simplicité nous avons préféré leur donner des valeurs fixes.

2 Mise en œuvre Java

Comme celui de l'étape précédente, tout le code de cette étape fait partie du paquetage ch.epfl.alpano.gui.

2.1 Classe Labelizer

La classe nommée p.ex. Labelizer, immuable, représente un étiqueteur de panorama.

Son constructeur prend en arguments un MNT continu et une liste de sommets, qui sont ceux chargés du fichier qui vous a été fourni.

Son unique méthode publique, nommée p.ex. labels, prend en argument les paramètres du panorama à étiqueter — de type PanoramaParameters, et pas PanoramaUserParameters — et retourne une liste de nœuds JavaFX (décrits plus bas) représentant les étiquettes à attacher au panorama.

Notez que pour déterminer la distance à laquelle un rayon lancé en direction du sommet atteint le sol, la méthode labels doit faire un unique appel à la méthode firstIntervalContainingRoot utilisant le même pas que celui utilisé pour le calcul du panorama, c-à-d 64 m. Le sommet est considéré visible si et seulement si la valeur produite par cet appel se trouve dans la zone de visibilité décrite plus haut.

En dehors de la méthode labels, nous vous conseillons d'ajouter à la classe Labelizer une méthode auxiliaire privée qui retourne la liste des sommets visibles.

2.1.1 Nœuds JavaFX

A chaque sommet étiqueté du panorama correspondent deux éléments graphiques : une étiquette textuelle et une ligne verticale liant l'étiquette au sommet. Avec JavaFX, l'étiquette peut être représentée par une instance de la classe Text, tandis que la ligne peut l'être par une instance de la classe Line. Ces deux classes héritent de la classe Node, qui représente un « nœud » JavaFX, terme désignant un élément graphique.

Lors d'une étape ultérieure, les nœuds produits par la méthode labels de la classe Labelizer seront fournis à JavaFX, qui se chargera de les superposer à l'image du panorama.

A chaque nœud JavaFX peuvent être attachées des transformations géométriques — rotations, translations, etc. Cette possibilité est très utile ici pour placer et tourner les étiquettes des sommets. L'extrait de code ci-dessous l'illustre en créant un nœud textuel et en le translatant de 1 unité en x, 2 en y et en lui faisant effectuer une rotation de 30° autour de son origine. L'origine des nœuds textuels est le coin bas-gauche du rectangle englobant le texte.

Text t = new Text("Bonjour !");
t.getTransforms().addAll(new Translate(1, 2),
                         new Rotate(30, 0, 0));

Pour plus de détails concernant ces transformations, consultez la documentation des classes Translate et Rotate, et celle de la méthode getTransforms.

2.1.2 Positionnement et tri des sommets

Les coordonnées des sommets visibles sur l'image, telles que retournées par les méthodes xForAzimuth et yForAltitude, ne sont généralement pas entières.

Du point de vue de JavaFX, cela ne pose pas de réel problème, étant donné que la position des nœuds est donnée par des valeurs en virgule flottante. Toutefois, l'utilisation de coordonnées non entières pour placer certain types de nœuds, en particulier des lignes horizontales ou verticales fines, peut produire des images peu agréables à l'œil.

Dès lors, la position des sommets visibles sur l'image d'un panorama est, pour les besoins de l'étiquetage, arrondie aux coordonnées entières les plus proches. Cela améliore d'une part l'apparence des lignes verticales reliant les étiquettes aux sommets, et facilite d'autre part la gestion de l'espacement des étiquettes, comme nous le verrons ci-dessous.

Une conséquence de l'arrondissement des coordonnées est qu'il devient plus probable que plusieurs sommets aient la même position verticale (arrondie). Pour les départager lors du parcours par ordre de priorité décrit à la section 1.1.2, leur altitude est utilisée comme second critère de tri, le plus haut sommet ayant la priorité.

2.1.3 Placement horizontal des étiquettes

Pour s'assurer que les étiquettes des sommets ne sont pas trop proches des bords et ne se chevauchent pas — comme demandé à la section 1.1.2 — plusieurs solutions existent.

L'une d'entre elles consiste à garder, lors de l'ajout des étiquettes, l'ensemble de toutes les positions horizontales auxquelles il est possible d'étiqueter un sommet. Initialement, cet ensemble contient toutes les positions situées à plus de 20 pixels des deux bords du panorama. Ensuite, chaque fois qu'une nouvelle étiquette devrait être ajoutée, on vérifie que la position qui lui correspond, ainsi que les 19 suivantes, sont utilisables. Si tel est le cas, l'étiquette est ajoutée, et ces positions supprimées de l'ensemble de celles utilisables. Sinon, l'étiquette n'est pas ajoutée et l'ensemble des positions utilisables ne change pas.

Pour représenter l'ensemble des positions utilisables, il serait bien entendu possible d'utiliser une instance d'une classe quelconque implémentant l'interface Set. Toutefois, une nettement meilleure solution existe : la classe BitSet, qui représente un ensemble d'entiers (non négatifs) au moyen d'un seul bit par entier. Lisez sa documentation pour voir comment l'utiliser.

2.2 Conversion de/vers chaîne avec JavaFX

Lors de la conception d'une interface graphique, il est souvent nécessaire de convertir des valeurs internes au programme en chaînes de caractères avant de les afficher à l'écran. De plus, l'utilisateur a parfois la possibilité d'éditer cette représentation textuelle d'une valeur, et une fois l'édition terminée, il faut la convertir à nouveau vers sa représentation interne.

Par exemple, l'interface utilisateur de ce projet donnera à l'utilisateur la possibilité de visualiser et de modifier l'azimut central du panorama. Cet azimut est, nous l'avons vu, un entier représentant un angle en degrés. Pour pouvoir être affiché, cet entier doit être transformée en chaîne de caractères. De plus, si l'utilisateur modifie cette chaîne de caractères, il faut ensuite convertir sa nouvelle valeur en entier.

En d'autres termes, il est souvent nécessaire dans une interface utilisateur de faire des transformations bidirectionnelles entre des valeurs d'un type donné et des chaînes de caractères. Dans ce but, JavaFX offre la classe abstraite et générique StringConverter<T>, qui représente un objet capable de convertir une donnée d'un type T en une chaîne de caractères, et vice versa. Elle offre pour cela deux méthodes (abstraites dans StringConverter) :

  • String toString(T v), qui convertit la valeur de type T donnée en chaîne de caractères, ou retourne la chaîne vide si la valeur donnée est nulle,
  • T fromString(String s), qui convertit la chaîne donnée en une valeur de type T.

Le comportement de la méthode fromString dans le cas où la chaîne ne peut pas être convertie en une valeur de type T n'est malheureusement pas spécifié dans la documentation de JavaFX. En pratique, il semble toutefois que lever une exception quelconque soit le comportement attendu par les utilisateurs des convertisseurs de chaîne.

Un certain nombre de sous-classes concrètes de StringConverter sont fournies avec JavaFX, p.ex. IntegerStringConverter qui permet de convertir des entiers et que nous utiliserons pour l'azimut central et tous les autres paramètres entiers. Deux cas ne sont toutefois pas prévus par JavaFX et requièrent donc des classes spécifiques :

  • le degré de suréchantillonnage est un entier valant 0, 1 ou 2, mais il est plus clair de le présenter à l'utilisateur sous une forme textuelle, p.ex. en représentant le degré 0 par le texte « non », le degré 1 par « 2× » et le degré 2 par « 4× »,
  • la longitude et la latitude de l'observateur sont stockés en interne en dix-millièmes de degrés mais doivent apparaître comme des nombres à virgule (fixe) à l'utilisateur ; p.ex. la longitude 471234 doit apparaître comme 47.1234.

Les deux classes ci-dessous ont pour but de gérer ces conversions.

2.3 Classe LabeledListStringConverter

La classe nommée p.ex. LabeledListStringConverter, héritant de StringConverter<Integer>, convertit entre chaînes de caractères et entiers.

La conversion se fait en fonction d'une liste de chaînes passées au constructeur sous la forme d'un nombre variable d'arguments : une chaîne égale à celle à la position n de cette liste est convertie en l'entier n, et vice versa.

L'extrait de programme ci-dessous illustre l'utilisation de ce convertisseur. Le commentaire attaché à chaque ligne montre le texte affiché.

LabeledListStringConverter c =
  new LabeledListStringConverter("zéro", "un", "deux");
System.out.println(c.fromString("deux")); // 2
System.out.println(c.toString(0));        // zéro

2.4 Classe FixedPointStringConverter

La classe nommée p.ex. FixedPointStringConverter, héritant de StringConverter<Integer>, convertit entre chaînes de caractères et entiers.

La conversion d'une chaîne en entier se fait en interprétant celle-ci comme un nombre réel, en l'arrondissant à un nombre de décimales fixes et spécifié au moment de la construction du convertisseur, puis en « supprimant la virgule » afin d'obtenir un entier.

Par exemple, en admettant que le nombre de décimales ait été fixé à 4, la chaîne 12.3456789 est convertie en un réel à 4 décimale, ce qui donne le nombre 12.3457. Ensuite, la virgule (ici un point) est supprimée pour obtenir l'entier 123457.

L'arrondissement au nombre de décimales spécifié se fait en choisissant la valeur la plus proche de celle à arrondir et ayant le bon nombre de décimales. Lorsque deux valeurs équidistantes de celle à arrondir existent, la plus grande est choisie. Ce comportement est spécifié par le mode d'arrondi nommé HALF_UP dans la bibliothèque Java, et est illustré dans l'exemple ci-dessous lorsque la chaîne 12.35 est transformée en l'entier 124.

La conversion d'un entier en chaîne suit le chemin inverse.

L'extrait de programme ci-dessous illustre l'utilisation de ce convertisseur.

FixedPointStringConverter c =
  new FixedPointStringConverter(1);

System.out.println(c.fromString("12"));       // 120
System.out.println(c.fromString("12.3"));     // 123
System.out.println(c.fromString("12.34"));    // 123
System.out.println(c.fromString("12.35"));    // 124
System.out.println(c.fromString("12.36789")); // 124

System.out.println(c.toString(678));          // 67.8

La conversion peut se faire très facilement au moyen de la classe BigDecimal et de ses méthodes movePointLeft, movePointRight, stripTrailingZeros, setScale, toPlainString et intValueExact, dont vous devriez comprendre l'utilisation en lisant leur documentation.

2.5 Tests

Pour valider votre étiqueteur de panorama, vous pouvez écrire un petit programme de test imprimant, pour un panorama donné :

  1. la liste des sommets visibles sur le panorama, triée par position verticale croissante (du haut vers le bas de l'image) puis par altitude décroissante,
  2. la liste des nœuds JavaFX correspondant aux étiquettes.

Pour le panorama prédéfini du Niesen, les 10 premiers éléments de la liste triée des sommets visibles devrait être (les valeurs entre parenthèses donnent la position du sommet sur l'image du panorama) :

NIESEN (1223, 157)
FROMBERGHORE (1437, 190)
DREISPITZ (595, 258)
JUNGFRAU (157, 259)
SCHWALMERE (341, 259)
FIRST (519, 262)
BLUEMLISALP (811, 263)
WYSSI FRAU (767, 263)
MORGENHORN (735, 263)
MOENCH (14, 264)

Les 10 premiers éléments de la liste des nœuds JavaFX correspondante devrait ressembler à ceci (pour clarifier la présentation, certains attributs peu intéressants ont été supprimés) :

Text[text="FROMBERGHORE (2394 m)", x=0.0, y=0.0, …]
Line[startX=1437.0, startY=170.0, endX=1437.0,endY=190.0,…]
Text[text="DREISPITZ (2520 m)", x=0.0, y=0.0, …]
Line[startX=595.0, startY=170.0, endX=595.0, endY=258.0, …]
Text[text="JUNGFRAU (4158 m)", x=0.0, y=0.0, …]
Line[startX=157.0, startY=170.0, endX=157.0, endY=259.0, …]
Text[text="SCHWALMERE (2777 m)", x=0.0, y=0.0, …]
Line[startX=341.0, startY=170.0, endX=341.0, endY=259.0, …]
Text[text="FIRST (2440 m)", x=0.0, y=0.0, …]
Line[startX=519.0, startY=170.0, endX=519.0, endY=262.0, …]

Plusieurs remarques peuvent être faites au sujet de cette liste de nœuds :

  1. aucune étiquette n'existe pour le Niesen car sa position verticale (157) est en deça de la limite (170),
  2. la ligne liant l'étiquette du premier sommet (Fromberghore) au sommet lui-même fait 20 pixels de long, comme attendu,
  3. toutes les lignes liant les étiquettes aux sommets commencent à la même position verticale, ici 170 — le fait qu'elle soit égale à la limite est un hasard,
  4. la position des étiquettes peut laisser penser qu'elles sont mal placées, mais cela est dû au fait que leur position est déterminée par une translation qui leur est attachée, comme dans l'exemple donné à la section 2.1.1 ; en réalité, les étiquettes sont alignées à la position verticale 168, deux pixels au-dessus du début des lignes.

3 Résumé

Pour cette étape, vous devez :

  • écrire les classes Labelizer, LabeledListStringConverter et FixedPointStringConverter (ou des équivalents) en fonction des indications données plus haut,
  • 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.