Forums Développement Multimédia

Les formations Mediabox
Les formations Mediabox

Flex Charts : manipuler les points des courbes

Compatible Flex 3. Cliquer pour en savoir plus sur les compatibilités.Compatible ActionScript 3. Cliquer pour en savoir plus sur les compatibilités.Par sebastien.portebois (Sébastien Portebois), le 08 juillet 2009

Introduction

Bonjour,

A l'étape précédente nous avons vu comment compléter un graphique Flex (mx.charts) avec des informations placées précisément en fonction des coordonnées des axes et des données calculées par le composant. Cette fois-ci nous allons aller un peu plus loin pour compléter le graphique en y ajoutant des éléments visuels directement dépendants des données, placés très précisément en fonction des courbes calculées par le composant.

Pour être plus concret, notre exemple portera sur la mise en avant des différences entre deux séries de valeurs, accentuant les périodes ou la série A a des valeurs supérieure à la série B, et accentuant de manière différente les moments où la situation s'inverse.

Cette mise en avant peut par exemple être utilisée pour illustrer les variations d'une valeur comparativement à ses valeurs moyennes sur des périodes précédentes, ou la variation du statistique d'une personne comparativement à la moyenne de cette statistique sur une population, … Et de manière plus générale, nous allons voir ici comment récupérer précisément les points de courbes pour effectuer une action, aussi nous avons dès lors un espace de possibilités infini pour compléter ces courbes avec tout type de visualisation.

La démarche suivie

Nous avons vu précédemment comment superposer un canvas avec notre graph. Pour cet exemple, nous aurons besoin d'un peu plus de points pour avoir quelque chose de parlant, aussi les données de test ont été sorties dans un document xml pour se concentrer sur le code dans le mxml.

<mx:Model id="datas" source="assets/datas.xml"/>

Les zones que nous allons tracer sont de simples polygones, pour lesquels nous allons devoir :
* connaitre les points des courbes
* repérer les intersections entre les courbes

Chaque polygone sera composé d'une suite de points, commencée soit par le début du graph, soit par la dernière intersection rencontrée, jusqu'à l'intersection suivante (ou la fin du graph).

Pour être plus précis, utilisons un exemple concret:

Sur ce résultat annoté, nous avons la courbe A composée des points {A1,A2,..An,..A12}, la courbe B avec le même nombre de points, ayant les même coordonnées X, mais des valeurs Y différentes, et 3 intersections I1, I2, I3 délimitant les changements de tendance.
Nous avons donc 4 polygones à tracer : 2 polygones avec A > B qui sont :
- {A1, I1, B1}
- {I2, A5, A6, A7, A8, A9, A10, A11, I3, B11, B10, B9, B8, B7, B6, B5}
et 2 polygones avec B > A qui sont - {I1, A2, A3, A4, I2, B4, B3, B2}
- {I3, A12, B12} (si on fait abstraction de la suite de la courbe, masquée ici pour rester concis)

Tracer un polygone est très simple, via un canvas.graphics.beginFill(..), puis des séries de moveTo() et lineTo().
Les coordonnées x et y des points devront être précisément celles des sommets des courbes. Pour ce faire nous allons pointer les LineSeries de notre charts, qui ont une propriété items du type Array /* of LineSeriesItem */. Chacune de ces instances de LineSeriesItem a une propriété x et y qui correspond à ses coordonnées dans le graph.

En résumé, le code pour récupérer les coordonnées x et y des points d'une serie par rapport à l'origine du graph se résume à ceci :

		var chart:LineChart = event.currentTarget.owner;
		var arPlotsVar:LineSeries = valeursVariables; // l'ID d'une de nos series du LineChart
		var itemsVar:Array = arPlotsVar.items;
 
 
		// (...)
		var chartItemVar:LineSeriesItem;
		for (var iter:int = 0; iter < itemsVar.length; iter++) {
 			chartItemVar = LineSeriesItem(itemsVar[iter]);
 
 			x1 = chartItemVar.x;
 			y1 = chartItemVar.y;
			//...

Récupérer les points x et y est donc simple, l'étape suivante va être de composer les listes des sommets des courbes pour tracer nos polygones. Savoir si une courbe est au dessus de l'autre est une simple comparaison sur les valeurs de y. Lorsqu'on remarque que la courbe du dessus n'est plus la même, c'est qu'il y a une intersection à calculer. Pour continuer avec notre exemple, à l'étape 4 nous avons A<B (la valeur y du point A4 étant supérieure à celle de B4). A l'itération suivante, on remarque que le résultat de cette comparaison des y s'inverse, et donc que la courbe A passe au dessus de celle des B. Nous allons donc calculer les coordonnées du point I2, intersection des segments {A4,A5} et {B4, B5}.
Dans les sources de l'exemple, vous trouverez la classe GeometryUtil qui se réduit pour cet exemple à une seule méthode statique qui va calculer ce point :

	/**
	 * Calcule l'intersection de 2 lignes définies par 4 points, et retourne
	 * l'intersection trouvée si elle existe
	 * @param x1 Point 1 de la ligne 1
	 * @param y1 Point 1 de la ligne 1
	 * @param x2 Point 2 de la ligne 1
	 * @param y2 Point 2 de la ligne 1
	 * @param x3 Point 1 de la ligne 2
	 * @param y3 Point 1 de la ligne 2
	 * @param x4 Point 2 de la ligne 2
	 * @param y4 Point 2 de la ligne 2
	 * @return Point ou null. Le point est l'intersection des segments. Null si aucune intersection
	 */
	public static function intersection(
		x1:Number, y1:Number,
		x2:Number, y2:Number,
		x3:Number, y3:Number,
		x4:Number, y4:Number):Point
		{
		var d:Number = (x1-x2)*(y3-y4) - (y1-y2)*(x3-x4);
		if (d == 0)
			return null;
 
		var xi:Number = ((x3-x4)*(x1*y2-y1*x2)-(x1-x2)*(x3*y4-y3*x4))/d;
		var yi:Number = ((y3-y4)*(x1*y2-y1*x2)-(y1-y2)*(x3*y4-y3*x4))/d;
 
		return new Point(xi,yi);
	}

Nous avons donc désormais les coordonnées de tout nos points, nous savons reconnaitre le début et la fin d'un polygone (lorsqu'une courbe passe au dessus de l'autre). Il nous reste surtout à trouver un moyen astucieux de stocker nos éléments.
En effet, si nous nous contentons de concaténer les points au fur et à mesure que nous incrémentons les x pour tester, nous allons nous retrouver avec une liste ressemblant à [I1,A2,B2,A3,B3,A4,B4,I2] pour le premier polygone bleu de notre exemple. Si on essaye de faire une succession de lineTo() avec ces points, l'ordre dans lequel ils sont stockés ne nous donnera pas l'enveloppe des 2 courbes.
En regardant le graph on constate que la forme du polygone devrait être définie par l'array a1 = [I1, A2, A3, A4, I2, B4, B3, B2]. En permutant les indices cette enveloppe de polygone est en fait exactement la même que l'array a2 =[B4, B3, B2, I1, A2, A3, A4, I2]. Cette petite reformulation vous a peut-être déjà mis sur la piste. En incrémentant les x, les points que nous allons manipuler sont, dans l'ordre, I1, puis A2 et B2, puis A3 et B3, puis A4 et B4, puis I2. La symétrie du tableau a2 nous donne la solution.
En pseudo code je commencerai avec un tableau ar = [I1] initialisé lorsqu'on détecte la création d'un nouveau polygone (c'est à dire lorsque les courbes se croisent.
A l'étape A2,B2, il nous suffit de faire

	ar.push(A2);
	ar.unshift(B2);

pour avoir ar == [B2, I2, A2]. A l'étape suivante nous aurons

	ar.push(A3);
	ar.unshift(B3);

ce qui nous donnera ar == [B3, B2, I2, A2, A3]. A l'étape suivante nous obtenons ar == [B4, B3, B2, I2, A2, A3, A4]. Enfin nous détectons une intersection, que nous ajoutons à la liste pour fermer le polygone, et nous obtenons naturellement le tableau a2 qui délimite précisément le premier polygone où la courbe A est en dessous de la courbe B.

Il nous suffit alors d'avoir deux tableaux, l'un stockant les listes de points décrivant les polygones où A > B (les polygones rouges). Et un autre stockant les autres polygones, lorsque B > A. Une fois toutes les valeurs y de nos LineSeries traitées, il nous suffit de faire un canvas.graphics.clear(), puis d'enchainer un beginFill(..) pour la couleur des polygones rouges, et parcourir chacune des listes et faire de simples lineTo. Une fois les listes traitées, un autre beginFill pour changer de couleur et nous traitons la seconde série de polygones.

Pour être plus concret, la méthode drawPolys ci dessous va parcourir la liste de points pour faire ces dessins, dans le contexte graphics g passé en argument, auquel on aura fait le beginFill juste auparavant:

	/**
	 * drawPolys dessine plusieurs polygones dans le contexte graphic g 
	 * @param g du style canvas.graphics
	 * @param polyNodesList : Array of Array of Array of Number. Chaque 
	 *  polygone est défini par un Array de descripteur de point, ce 
	 *  descripteur étant un Array(x,y). L'ensemble des polygones 
	 *  étant lui regroupé dans un Array.
	 */
	private function drawPolys(g:Graphics, polyNodesList:Array):void
	{
		var areaCnt:int;
 		var nodeInd:int;
 		var nodeCnt:int;
 		var arNodes:Array;
 		areaCnt = polyNodesList.length;
 		for (var iter:int = 0; iter < areaCnt; iter++) 
		{
 			// On parcours chaque polygone défini dans polyNodesList  
 			arNodes = polyNodesList[iter];
 			nodeCnt = arNodes.length;
 			if (nodeCnt)
			{
 				// Pour chaque polygone on trace une ligne entre tous les points
		 		g.moveTo(arNodes[0][0], arNodes[0][1]);
				for (nodeInd = 1; nodeInd < nodeCnt; nodeInd++) 
				{
					g.lineTo(arNodes[nodeInd][0], arNodes[nodeInd][1]);
				}
				// On ferme le polygone
		 		g.lineTo(arNodes[0][0], arNodes[0][1]);
	 		}
	    }
	}

Cette méthode sera appelée par la méthode de calcul des polygones dont on a expliqué la logique au dessus. Une fois les tests ajoutés pour gérer le début et la fin du graph, nous obtenons la méthode drawCurveAreas ci-dessous. Dans cet exemple les deux branches du if gérant le cas A>B et le cas B>A sont très redondantes, et la première optimisation serait de supprimer cette redondance. Néanmoins cette version à l'avantage d'être lisible et explicite, et conviens je pense mieux à des fins d'explications.

private function drawCurveAreas(event:Event=null):void {
 
		// on défini les paramètres de dessin
		var colorAbove:uint = 0xFF0000; // Rouge si au dessus
		var colorUnder:uint = 0x0000FF; // Bleu en dessous
		var canvas:Canvas = areaBgCanvas;
		var chart:LineChart = event.currentTarget.owner;
 
		// Ce qui va nous intéresser pour le tracés : 2 lineSeries dont on récupère les items
		var arPlotsVar:LineSeries = valeursVariables;
		var arPlotsMed:LineSeries = valeursMoyennes;
 
		var itemsVar:Array = arPlotsVar.items;
		var itemsMed:Array = arPlotsMed.items;
		var chartItemVar:LineSeriesItem;
		var chartItemMed:LineSeriesItem;
 
		if (itemsVar.length<1)
			return;
 
		// On va lister les points composants les polygones au dessus et en 
		// dessous séparément pour pouvoir les tracer différemment
		var arShapesNodesOver:Array = [];
		var arShapesNodesUnder:Array = [];
		// Pour composer facilement la liste des points dans l'ordre du tracé, 
		// on va avoir 2 liste, celle avec les points du haut et celle avec les 
		// points du bas. En incrémentant les x, on ajouter 
		// la nouvelle valeur à la fin de la liste du haut
		// et au début de la liste du bas. Ainsi en les concaténant à la fin du 
		// traitement, on aura le tracé continu du polygone directement exploitable. 
 		var arCurrentNodes:Array = [];
 
 		var bCurrentMesureIsOver:Boolean;
 		var spotCnt:int = itemsVar.length;
 
 		var iter:int;
 		var graphX:Number = chart.x;
 		var graphY:Number = chart.y;
 		var x1:Number, y1:Number, x2:Number, y2:Number;
 		var xa:Number, ya:Number, xb:Number, yb:Number;
 
 		var intersect:Point; 
 
 		for (iter = 0; iter < itemsVar.length; iter++) {
 			chartItemVar = LineSeriesItem(itemsVar[iter]);
 			chartItemMed = LineSeriesItem(itemsMed[iter]);
 
 			x1 = chartItemVar.x;
 			y1 = chartItemVar.y;
 			x2 = chartItemMed.x;
 			y2 = chartItemMed.y;
 
 
 			// Pour des raisons de lisibilité j'ai laissé le code de la logique 
 			// au dessus/en dessous de la moyenne quasi dupliqué dans chacune 
 			// des branches du if. Cette redondance est nuisible à la maintenance
 			// du code mais facilite sa première compréhension, raison pour 
 			// laquelle j'ai conservé cette formulation pour le tutorial.
 
 			if (y1 <= y2) {
 				// on est au dessus de la moyenne
 				if (bCurrentMesureIsOver) {
 					// On continue la zone en cours
 					// L'astuce ici est de faire un push dans d'une série 
 					// et un unshift de l'autre, pour avoir les points dans le bon 
 					// ordre lorsqu'on fera un concat pour les réunir.
 					arCurrentNodes.push([x1, y1]);
 					arCurrentNodes.unshift([x2, y2]);
 
 
 				} else {
 					// On passe d'une zone en dessous à une zone au dessus
 					bCurrentMesureIsOver = true;
 					// On cherche l'intersection si ce n'est pas la première itération
 					intersect = null;
 					if (arCurrentNodes.length > 0) 
					{
 						xa = arCurrentNodes[arCurrentNodes.length-1][0];
 						ya = arCurrentNodes[arCurrentNodes.length-1][1];
 						xb = arCurrentNodes[0][0];
 						yb = arCurrentNodes[0][1];
 						intersect = GeometryUtil.intersection(xa, ya, x1, y1, xb, yb, x2, y2);
 					}
 					if (intersect != null) 
 						arCurrentNodes.push([intersect.x, intersect.y]);
 
 
 					// Et on ajoute cette zone
 					arShapesNodesUnder.push(arCurrentNodes);
 
 					// et on stock les nouveaux points du début de la forme
 					if (intersect != null) {
 						arCurrentNodes = [[intersect.x, intersect.y], [x1, y1]];
 					} else {
 						arCurrentNodes = [[x1, y1]];
 					}
 					arCurrentNodes.unshift([x2, y2]);
 
 				}
 			} else {
 				// On est en deça de la moyenne
 				if (!bCurrentMesureIsOver) {
 					// on continue la zone en cours
 					arCurrentNodes.push([x1, y1]);
 					arCurrentNodes.unshift([x2, y2]);
 
 				} else {
 					// On passe d'une zone au dessus à une zone en dessous 
 					bCurrentMesureIsOver = false;
 					// On cherche l'intersection si ce n'est pas la première itération
 					intersect = null;
 					if (arCurrentNodes.length > 0) 
					{
 						xa = arCurrentNodes[arCurrentNodes.length-1][0];
 						ya = arCurrentNodes[arCurrentNodes.length-1][1];
 						xb = arCurrentNodes[0][0];
 						yb = arCurrentNodes[0][1];
 						intersect = GeometryUtil.intersection(xa, ya, x1, y1, xb, yb, x2, y2);
 					}
 					if (intersect != null)
 						arCurrentNodes.push([intersect.x, intersect.y]);
 
 					// Et on ajoute cette zone
 					arShapesNodesOver.push(arCurrentNodes);
 
 					// et on stock les nouveaux points du début de la forme
 					if (intersect != null) {
 						arCurrentNodes = [[intersect.x, intersect.y], [x1, y1]];
 					} else {
 						arCurrentNodes = [[x1, y1]];
 					}
 					arCurrentNodes.unshift([x2, y2]);
 
 				}
 			}
 
 		}
 
 		// On ferme la dernière forme en cours
 		if (bCurrentMesureIsOver) {
 			arShapesNodesOver.push(arCurrentNodes);
 		} else {
 			arShapesNodesUnder.push(arCurrentNodes);
 		}
 
 
 		// On dessine les formes
 		var g:Graphics = canvas.graphics;
 		g.clear();
 		// Formes au dessus
 		g.beginFill(colorAbove, 0.25);
 		drawPolys(g, arShapesNodesOver);
 
 		// Formes en dessous
 		g.beginFill(colorUnder, 0.25);
 		drawPolys(g, arShapesNodesUnder);
	}

Dernier détail, il faut que cette routine de calcul des polygones et de dessin soit appelée au bon moment, pour ceci il suffit de la déclarer sur l'événement updateComplete de nos LineSeries:

 	<mx:LineSeries id="valeursMoyennes"
		yField="moyenne" displayName="Moyenne"
		updateComplete="drawCurveAreas(event)"
		>
		<mx:lineStroke>
			<mx:Stroke color="0x0000FF" />
		</mx:lineStroke>
	</mx:LineSeries>

Et voici le résultat:

L"extension Adobe Flash Plugin est nécessaire pour afficher ce contenu.

En savoir plus

Vous pouvez télécharger ici les sources Flex 3
Pour continuer à travailler avec les mx.charts, la documentation officielle des charting components regorge d'informations et reste d'une grande aide : http://livedocs.adobe.com/flex/3/html/Part1_charting_1.html#72388

Aller plus loin

Nous avons parcouru comment récupérer les valeurs de nos séries pour tracer un complément d'info. Les limitations des styles des charts ne sont donc plus une limite pour enrichir l'information que vous délivrez à l'utilisateur. Avec ces coordonnées x et y des items des series du chart, il suffit d'ajouter le x et y du chart et ses gutters left et top, et nous avons la position x,y dans le container, que nous pouvons utiliser pour placer un autre composant plus sophistiqué pour afficher des tooltips enrichis, recouvrir des zones, ajouter des commentaires, …