Jelajahi Sumber

Replacement of the impact Flash based analysis graph by graphviz + Raphael + TCPDF. ALPHA version.

git-svn-id: http://svn.code.sf.net/p/itop/code/trunk@3554 a333f486-631f-4898-b8df-5754b55c2be0
dflaven 10 tahun lalu
induk
melakukan
0ea7084670

+ 10 - 3
application/displayblock.class.inc.php

@@ -1412,13 +1412,20 @@ class MenuBlock extends DisplayBlock
 					}
 				}
 				// Relations...
-				$aRelations = MetaModel::EnumRelations($sClass);
+				$aRelations = MetaModel::EnumRelationsEx($sClass);
 				if (count($aRelations))
 				{
 					$this->AddMenuSeparator($aActions);
-					foreach($aRelations as $sRelationCode)
+					foreach($aRelations as $sRelationCode => $aRelationInfo)
 					{
-						$aActions[$sRelationCode] = array ('label' => MetaModel::GetRelationLabel($sRelationCode), 'url' => "{$sRootUrl}pages/$sUIPage?operation=swf_navigator&relation=$sRelationCode&class=$sClass&id=$id{$sContext}");
+						if (array_key_exists('down', $aRelationInfo))
+						{
+							$aActions[$sRelationCode.'_down'] = array ('label' => $aRelationInfo['down'], 'url' => "{$sRootUrl}pages/$sUIPage?operation=swf_navigator&relation=$sRelationCode&direction=down&class=$sClass&id=$id{$sContext}");
+						}
+						if (array_key_exists('up', $aRelationInfo))
+						{
+							$aActions[$sRelationCode.'_up'] = array ('label' => $aRelationInfo['up'], 'url' => "{$sRootUrl}pages/$sUIPage?operation=swf_navigator&relation=$sRelationCode&direction=up&class=$sClass&id=$id{$sContext}");
+						}
 					}
 				}
 				/*

+ 938 - 0
core/displayablegraph.class.inc.php

@@ -0,0 +1,938 @@
+<?php
+// Copyright (C) 2015 Combodo SARL
+//
+//   This file is part of iTop.
+//
+//   iTop is free software; you can redistribute it and/or modify
+//   it under the terms of the GNU Affero General Public License as published by
+//   the Free Software Foundation, either version 3 of the License, or
+//   (at your option) any later version.
+//
+//   iTop is distributed in the hope that it will be useful,
+//   but WITHOUT ANY WARRANTY; without even the implied warranty of
+//   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+//   GNU Affero General Public License for more details.
+//
+//   You should have received a copy of the GNU Affero General Public License
+//   along with iTop. If not, see <http://www.gnu.org/licenses/>
+
+/**
+ * Special kind of Graph for producing some nice output
+ *
+ * @copyright   Copyright (C) 2015 Combodo SARL
+ * @license     http://opensource.org/licenses/AGPL-3.0
+ */
+
+class DisplayableNode extends GraphNode
+{
+	public $x;
+	public $y;
+	
+	/**
+	 * Create a new node inside a graph
+	 * @param SimpleGraph $oGraph
+	 * @param string $sId The unique identifier of this node inside the graph
+	 * @param number $x Horizontal position
+	 * @param number $y Vertical position
+	 */
+	public function __construct(SimpleGraph $oGraph, $sId, $x = 0, $y = 0)
+	{
+		parent::__construct($oGraph, $sId);
+		$this->x = $x;
+		$this->y = $y;
+		$this->bFiltered = false;
+	}
+
+	public function GetIconURL()
+	{
+		return $this->GetProperty('icon_url', '');
+	}
+	
+	public function GetLabel()
+	{
+		return $this->GetProperty('label', $this->sId);
+	}
+	
+	public function GetWidth()
+	{
+		return max(32, 5*strlen($this->GetProperty('label'))); // approximation of the text's bounding box
+	}
+	
+	public function GetHeight()
+	{
+		return 32;
+	}
+	
+	public function Distance2(DisplayableNode $oNode)
+	{
+		$dx = $this->x - $oNode->x;
+		$dy = $this->y - $oNode->y;
+		
+		$d2 = $dx*$dx + $dy*$dy - $this->GetHeight()*$this->GetHeight();
+		if ($d2 < 40)
+		{
+			$d2 = 40;
+		}
+		return $d2;
+	}
+	
+	public function Distance(DisplayableNode $oNode)
+	{
+		return sqrt($this->Distance2($oNode));
+	}
+	
+	public function GetForRaphael()
+	{
+		$aNode = array();
+		$aNode['shape'] = 'icon';
+		$aNode['icon_url'] = $this->GetIconURL();
+		$aNode['width'] = 32;
+		$aNode['source'] = ($this->GetProperty('source') == true);
+		$aNode['sink'] = ($this->GetProperty('sink') == true);
+		$aNode['x'] = $this->x;
+		$aNode['y']= $this->y;
+		$aNode['label'] = $this->GetLabel();
+		$aNode['id'] = $this->GetId();
+		$fOpacity = ($this->GetProperty('is_reached') ? 1 : 0.4);
+		$aNode['icon_attr'] = array('opacity' => $fOpacity);		
+		$aNode['text_attr'] = array('opacity' => $fOpacity);		
+		return $aNode;
+	}
+	
+	public function RenderAsPDF(TCPDF $oPdf, $fScale)
+	{
+		$Alpha = 1.0;
+		$oPdf->SetFillColor(200, 200, 200);
+		$oPdf->setAlpha(1);
+		
+		$sIconUrl = $this->GetProperty('icon_url');
+		$sIconPath = str_replace(utils::GetAbsoluteUrlModulesRoot(), APPROOT.'env-production/', $sIconUrl);
+		
+		if ($this->GetProperty('source'))
+		{
+			$oPdf->SetLineStyle(array('width' => 2*$fScale, 'cap' => 'round', 'join' => 'miter', 'dash' => 0, 'color' => array(204, 51, 51)));
+			$oPdf->Circle($this->x * $fScale, $this->y * $fScale, 16 * 1.25 * $fScale, 0, 360, 'D');
+		}
+		else if ($this->GetProperty('sink'))
+		{
+			$oPdf->SetLineStyle(array('width' => 2*$fScale, 'cap' => 'round', 'join' => 'miter', 'dash' => 0, 'color' => array(51, 51, 204)));
+			$oPdf->Circle($this->x * $fScale, $this->y * $fScale, 16 * 1.25 * $fScale, 0, 360, 'D');
+		}
+		
+		if (!$this->GetProperty('is_reached'))
+		{
+			if (function_exists('imagecreatefrompng'))
+			{
+				$im = imagecreatefrompng($sIconPath);
+				
+				if($im && imagefilter($im, IMG_FILTER_COLORIZE, 255, 255, 255))
+				{
+					$sTempImageName = APPROOT.'data/tmp-'.basename($sIconPath);
+					imagesavealpha($im, true);
+					imagepng($im, $sTempImageName);
+					imagedestroy($im);
+					$oPdf->Image($sTempImageName, ($this->x - 16)*$fScale, ($this->y - 16)*$fScale, 32*$fScale, 32*$fScale);
+				}
+			}
+			$Alpha = 0.4;
+			$oPdf->setAlpha($Alpha);
+		}
+		
+		$oPdf->Image($sIconPath, ($this->x - 16)*$fScale, ($this->y - 16)*$fScale, 32*$fScale, 32*$fScale);
+		//$oPdf->Image(APPROOT.'images/blank-100x100.png', ($this->x - 16)*$fScale, ($this->y - 16)*$fScale, 32*$fScale, 32*$fScale, '', '', '', false, 300, '', false, $mask);
+		//Image($file, $x='', $y='', $w=0, $h=0, $type='', $link='', $align='', $resize=false, $dpi=300, $palign='', $ismask=false, $imgmask=false, $border=0, $fitbox=false, $hidden=false, $fitonpage=false, $alt=false, $altimgs=array())
+		
+		$oPdf->SetFont('Helvetica', '', 24 * $fScale, '', true);
+		$width = $oPdf->GetStringWidth($this->GetProperty('label'));
+		$height = $oPdf->GetStringHeight(1000, $this->GetProperty('label'));
+		$oPdf->setAlpha(0.6 * $Alpha);
+		$oPdf->SetFillColor(255, 255, 255);
+		$oPdf->SetDrawColor(255, 255, 255);
+		$oPdf->Rect($this->x*$fScale - $width/2, ($this->y + 18)*$fScale, $width, $height, 'DF');
+		$oPdf->setAlpha($Alpha);
+		$oPdf->SetTextColor(0, 0, 0);
+		$oPdf->Text($this->x*$fScale - $width/2, ($this->y + 18)*$fScale, $this->GetProperty('label'));
+	}
+	
+	public function GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp = false, $bDirectionDown = true)
+	{
+//echo "<p>".$this->GetProperty('label').":</p>";
+		
+		if ($this->GetProperty('grouped') === true) return;
+		$this->SetProperty('grouped', true);
+			
+		if ($bDirectionDown)
+		{
+			$aNodesPerClass = array();
+			foreach($this->GetOutgoingEdges() as $oEdge)
+			{
+				$oNode = $oEdge->GetSinkNode();
+				
+				if ($oNode->GetProperty('class') !== null)
+				{
+					$sClass = $oNode->GetProperty('class');
+					if (($sClass!== null) && (!array_key_exists($sClass, $aNodesPerClass)))
+					{
+						$aNodesPerClass[$sClass] = array(
+							'reached' => array(
+								'count' => 0,
+								'nodes' => array(),
+								'icon_url' => $oNode->GetProperty('icon_url'),
+							),
+							'not_reached' => array(
+								'count' => 0,
+								'nodes' => array(),
+								'icon_url' => $oNode->GetProperty('icon_url'),
+							)
+						);
+					}
+					$sKey = $oNode->GetProperty('is_reached') ? 'reached' : 'not_reached';
+					if (!array_key_exists($oNode->GetId(), $aNodesPerClass[$sClass][$sKey]['nodes']))
+					{
+						$aNodesPerClass[$sClass][$sKey]['nodes'][$oNode->GetId()] = $oNode;
+						$aNodesPerClass[$sClass][$sKey]['count'] += (int)$oNode->GetProperty('count', 1);
+//echo "<p>New count: ".$aNodesPerClass[$sClass][$sKey]['count']."</p>";
+					}
+						
+				}
+				else
+				{
+					$oNode->GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp, $bDirectionDown);
+				}
+			}
+			
+			foreach($aNodesPerClass as $sClass => $aDefs)
+			{
+				foreach($aDefs as $sStatus => $aGroupProps)
+				{
+//echo "<p>$sClass/$sStatus: {$aGroupProps['count']} object(s), actually: ".count($aGroupProps['nodes'])."</p>";
+					if (count($aGroupProps['nodes']) >= $iThresholdCount)
+					{
+						$oNewNode = new DisplayableGroupNode($oGraph, $this->GetId().'::'.$sClass);
+						$oNewNode->SetProperty('label', 'x'.$aGroupProps['count']);
+						$oNewNode->SetProperty('icon_url', $aGroupProps['icon_url']);
+						$oNewNode->SetProperty('class', $sClass);
+						$oNewNode->SetProperty('is_reached', ($sStatus == 'reached'));
+						$oNewNode->SetProperty('count', $aGroupProps['count']);
+						//$oNewNode->SetProperty('grouped', true);
+						
+						$oIncomingEdge = new DisplayableEdge($oGraph, $this->GetId().'-'.$oNewNode->GetId(), $this, $oNewNode);
+										
+						foreach($aGroupProps['nodes'] as $oNode)
+						{
+							foreach($oNode->GetIncomingEdges() as $oEdge)
+							{
+								if ($oEdge->GetSourceNode()->GetId() !== $this->GetId())
+								{
+									$oNewEdge = new DisplayableEdge($oGraph, $oEdge->GetId().'::'.$sClass, $oEdge->GetSourceNode(), $oNewNode);
+								}
+							}
+							foreach($oNode->GetOutgoingEdges() as $oEdge)
+							{
+								$aOutgoing[] = $oEdge->GetSinkNode();
+								try
+								{
+									$oNewEdge = new DisplayableEdge($oGraph, $oEdge->GetId().'::'.$sClass, $oNewNode, $oEdge->GetSinkNode());
+								}
+								catch(Exception $e)
+								{
+									// ignore this edge
+								}
+							}
+							if ($oGraph->GetNode($oNode->GetId()))
+							{
+								$oGraph->_RemoveNode($oNode);
+							}
+						}
+						$oNewNode->GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp, $bDirectionDown);
+					}
+					else
+					{
+						foreach($aGroupProps['nodes'] as $oNode)
+						{
+							$oNode->GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp, $bDirectionDown);
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+class DisplayableRedundancyNode extends DisplayableNode
+{
+	public function GetWidth()
+	{
+		return 24;
+	}
+	
+	public function GetForRaphael()
+	{
+		$aNode = array();
+		$aNode['shape'] = 'disc';
+		$aNode['icon_url'] = $this->GetIconURL();
+		$aNode['source'] = ($this->GetProperty('source') == true);
+		$aNode['width'] = $this->GetWidth();
+		$aNode['x'] = $this->x;
+		$aNode['y']= $this->y;
+		$aNode['label'] = $this->GetLabel();
+		$aNode['id'] = $this->GetId();	
+		$fDiscOpacity = ($this->GetProperty('is_reached') ? 1 : 0.2);
+		$aNode['disc_attr'] = array('stroke-width' => 3, 'stroke' => '#000', 'fill' => '#c33', 'opacity' => $fDiscOpacity);
+		$fTextOpacity = ($this->GetProperty('is_reached') ? 1 : 0.4);
+		$aNode['text_attr'] = array('fill' => '#fff', 'opacity' => $fTextOpacity);		
+		return $aNode;
+	}
+
+	public function RenderAsPDF(TCPDF $oPdf, $fScale)
+	{
+		$oPdf->SetAlpha(1);
+		$oPdf->SetFillColor(200, 0, 0);
+		$oPdf->SetDrawColor(0, 0, 0);
+		$oPdf->Circle($this->x*$fScale, $this->y*$fScale, 16*$fScale, 0, 360, 'DF');
+
+		$oPdf->SetTextColor(255, 255, 255);
+		$oPdf->SetFont('Helvetica', '', 28 * $fScale, '', true);
+		$sLabel  = (string)$this->GetProperty('label');
+		$width = $oPdf->GetStringWidth($sLabel, 'Helvetica', 'B', 24*$fScale);
+		$height = $oPdf->GetStringHeight(1000, $sLabel);
+		$xPos = (float)$this->x*$fScale - $width/2;
+		$yPos = (float)$this->y*$fScale - $height/2;
+//		$oPdf->Rect($xPos, $yPos, $width, $height, 'D');
+//		$oPdf->Text($xPos, $yPos, $sLabel);
+		
+		$oPdf->SetXY(($this->x - 16)*$fScale, ($this->y - 16)*$fScale);
+		
+		// text on center
+		$oPdf->Cell(32*$fScale, 32*$fScale, $sLabel, 0, 0, 'C', 0, '', 0, false, 'T', 'C');
+	}
+	
+	public function GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp = false, $bDirectionDown = true)
+	{
+		parent::GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp, $bDirectionDown);
+		
+		if ($bDirectionUp)
+		{
+			$aNodesPerClass = array();
+			foreach($this->GetIncomingEdges() as $oEdge)
+			{
+				$oNode = $oEdge->GetSourceNode();
+		
+				if (($oNode->GetProperty('class') !== null) && (!$oNode->GetProperty('is_reached')))
+				{
+					$sClass = $oNode->GetProperty('class');
+					if (!array_key_exists($sClass, $aNodesPerClass))
+					{
+						$aNodesPerClass[$sClass] = array('reached' => array(), 'not_reached' => array());
+					}
+					$aNodesPerClass[$sClass][$oNode->GetProperty('is_reached') ? 'reached' : 'not_reached'][] = $oNode;
+				}
+				else
+				{
+					//$oNode->GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp, $bDirectionDown);
+				}
+			}
+
+			foreach($aNodesPerClass as $sClass => $aDefs)
+			{
+				foreach($aDefs as $sStatus => $aNodes)
+				{
+//echo "<p>".$this->GetId().' has '.count($aNodes)." neighbours of class $sClass in status $sStatus\n";
+					if (count($aNodes) >= $iThresholdCount)
+					{
+						$oNewNode = new DisplayableGroupNode($oGraph, '-'.$this->GetId().'::'.$sClass.'/'.$sStatus);
+						$oNewNode->SetProperty('label', 'x'.count($aNodes));
+						$oNewNode->SetProperty('icon_url', $aNodes[0]->GetProperty('icon_url'));
+						$oNewNode->SetProperty('is_reached', $aNodes[0]->GetProperty('is_reached'));
+							
+						$oOutgoingEdge = new DisplayableEdge($oGraph, '-'.$this->GetId().'-'.$oNewNode->GetId().'/'.$sStatus, $oNewNode, $this);
+		
+						foreach($aNodes as $oNode)
+						{
+							foreach($oNode->GetIncomingEdges() as $oEdge)
+							{
+								$oNewEdge = new DisplayableEdge($oGraph, '-'.$oEdge->GetId().'::'.$sClass, $oEdge->GetSourceNode(), $oNewNode);
+							}
+							foreach($oNode->GetOutgoingEdges() as $oEdge)
+							{
+								if ($oEdge->GetSinkNode()->GetId() !== $this->GetId())
+								{
+									$aOutgoing[] = $oEdge->GetSinkNode();
+									$oNewEdge = new DisplayableEdge($oGraph, '-'.$oEdge->GetId().'::'.$sClass.'/'.$sStatus, $oNewNode, $oEdge->GetSinkNode());
+								}
+							}
+//echo "<p>Replacing ".$oNode->GetId().' by '.$oNewNode->GetId()."\n";
+							$oGraph->_RemoveNode($oNode);
+						}
+						//$oNewNode->GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp, $bDirectionDown);
+					}
+					else
+					{
+						foreach($aNodes as $oNode)
+						{
+							//$oNode->GroupSimilarNeighbours($oGraph, $iThresholdCount, $bDirectionUp, $bDirectionDown);
+						}
+					}
+				}
+			}
+		}
+	}
+}
+
+class DisplayableEdge extends GraphEdge
+{
+	public function RenderAsPDF(TCPDF $oPdf, $fScale)
+	{
+		$xStart = $this->GetSourceNode()->x * $fScale;
+		$yStart = $this->GetSourceNode()->y * $fScale;
+		$xEnd = $this->GetSinkNode()->x * $fScale;
+		$yEnd = $this->GetSinkNode()->y * $fScale;
+		
+		$bReached = ($this->GetSourceNode()->GetProperty('is_reached') && $this->GetSinkNode()->GetProperty('is_reached'));
+		
+		$oPdf->setAlpha(1);
+		if ($bReached)
+		{
+			$aColor = array(100, 100, 100);
+		}
+		else
+		{
+			$aColor = array(200, 200, 200);
+		}
+		$oPdf->SetLineStyle(array('width' => 2*$fScale, 'cap' => 'round', 'join' => 'miter', 'dash' => 0, 'color' => $aColor));
+		$oPdf->Line($xStart, $yStart, $xEnd, $yEnd);
+		
+		
+		$vx = $xEnd - $xStart;
+		$vy = $yEnd - $yStart;
+		$l = sqrt($vx*$vx + $vy*$vy);
+		$vx = $vx / $l;
+		$vy = $vy / $l;
+		$ux = -$vy;
+		$uy = $vx;
+		$lPos = max($l/2, $l - 40*$fScale);
+		$iArrowSize = 5*$fScale;
+		
+		$x = $xStart  + $lPos * $vx;
+		$y = $yStart + $lPos * $vy;
+		$oPdf->Line($x, $y, $x + $iArrowSize * ($ux-$vx), $y + $iArrowSize * ($uy-$vy));
+		$oPdf->Line($x, $y, $x - $iArrowSize * ($ux+$vx), $y - $iArrowSize * ($uy+$vy));		
+	}
+}
+
+class DisplayableGroupNode extends DisplayableNode
+{
+	public function GetWidth()
+	{
+		return 50;
+	}
+
+	public function GetForRaphael()
+	{
+		$aNode = array();
+		$aNode['shape'] = 'group';
+		$aNode['icon_url'] = $this->GetIconURL();
+		$aNode['source'] = ($this->GetProperty('source') == true);
+		$aNode['width'] = $this->GetWidth();
+		$aNode['x'] = $this->x;
+		$aNode['y']= $this->y;
+		$aNode['label'] = $this->GetLabel();
+		$aNode['id'] = $this->GetId();
+		$fDiscOpacity = ($this->GetProperty('is_reached') ? 1 : 0.2);
+		$fTextOpacity = ($this->GetProperty('is_reached') ? 1 : 0.4);
+		$aNode['icon_attr'] = array('opacity' => $fTextOpacity);
+		$aNode['disc_attr'] = array('stroke-width' => 3, 'stroke' => '#000', 'fill' => '#fff', 'opacity' => $fDiscOpacity);
+		$aNode['text_attr'] = array('fill' => '#000', 'opacity' => $fTextOpacity);
+		return $aNode;
+	}
+	
+	public function RenderAsPDF(TCPDF $oPdf, $fScale)
+	{
+		$bReached = $this->GetProperty('is_reached');
+		$oPdf->SetFillColor(255, 255, 255);
+		if ($bReached)
+		{
+			$aBorderColor = array(100, 100, 100);
+		}
+		else
+		{
+			$aBorderColor = array(200, 200, 200);
+		}
+		$oPdf->SetLineStyle(array('width' => 2*$fScale, 'cap' => 'round', 'join' => 'miter', 'dash' => 0, 'color' => $aBorderColor));
+		
+		$sIconUrl = $this->GetProperty('icon_url');
+		$sIconPath = str_replace(utils::GetAbsoluteUrlModulesRoot(), APPROOT.'env-production/', $sIconUrl);
+		$oPdf->SetAlpha(1);
+		$oPdf->Circle($this->x*$fScale, $this->y*$fScale, $this->GetWidth() / 2 * $fScale, 0, 360, 'DF');
+		
+		if ($bReached)
+		{
+			$oPdf->SetAlpha(1);
+		}
+		else
+		{
+			$oPdf->SetAlpha(0.4);
+		}
+		$oPdf->Image($sIconPath, ($this->x - 17)*$fScale, ($this->y - 17)*$fScale, 16*$fScale, 16*$fScale);
+		$oPdf->Image($sIconPath, ($this->x + 1)*$fScale, ($this->y - 17)*$fScale, 16*$fScale, 16*$fScale);
+		$oPdf->Image($sIconPath, ($this->x -8)*$fScale, ($this->y +1)*$fScale, 16*$fScale, 16*$fScale);
+		$oPdf->SetFont('Helvetica', '', 24 * $fScale, '', true);
+		$width = $oPdf->GetStringWidth($this->GetProperty('label'));
+		$oPdf->SetTextColor(0, 0, 0);
+		$oPdf->Text($this->x*$fScale - $width/2, ($this->y + 25)*$fScale, $this->GetProperty('label'));
+	}
+}
+
+
+class DisplayableGraph extends SimpleGraph
+{
+	protected $sDirection;
+	
+	public static function FromRelationGraph(RelationGraph $oGraph, $iGroupingThreshold = 20, $bDirectionDown = true)
+	{
+		$oNewGraph = new DisplayableGraph();
+		
+		$oNodesIter = new RelationTypeIterator($oGraph, 'Node');
+		foreach($oNodesIter as $oNode)
+		{
+			switch(get_class($oNode))
+			{
+				case 'RelationObjectNode':				
+				$oNewNode = new DisplayableNode($oNewGraph, $oNode->GetId(), 0, 0);
+				
+				if ($oNode->GetProperty('source'))
+				{
+					$oNewNode->SetProperty('source', true);
+				}
+				if ($oNode->GetProperty('sink'))
+				{
+					$oNewNode->SetProperty('sink', true);
+				}
+				$oObj = $oNode->GetProperty('object');
+				$oNewNode->SetProperty('class', get_class($oObj));
+				$oNewNode->SetProperty('icon_url', $oObj->GetIcon(false));
+				$oNewNode->SetProperty('label', $oObj->GetRawName());
+				$oNewNode->SetProperty('is_reached', $bDirectionDown ? $oNode->GetProperty('is_reached') : true); // When going "up" is_reached does not matter
+				$oNewNode->SetProperty('developped', $oNode->GetProperty('developped'));
+				break;
+				
+				default:
+				$oNewNode = new DisplayableRedundancyNode($oNewGraph, $oNode->GetId(), 0, 0);
+				$oNewNode->SetProperty('label', $oNode->GetProperty('min_up'));
+				$oNewNode->SetProperty('is_reached', true);
+			}
+		}
+		$oEdgesIter = new RelationTypeIterator($oGraph, 'Edge');
+		foreach($oEdgesIter as $oEdge)
+		{
+			$oSourceNode = $oNewGraph->GetNode($oEdge->GetSourceNode()->GetId());
+			$oSinkNode = $oNewGraph->GetNode($oEdge->GetSinkNode()->GetId());
+			$oNewEdge = new DisplayableEdge($oNewGraph, $oEdge->GetId(), $oSourceNode, $oSinkNode);
+		}
+		
+		// Remove duplicate edges between two nodes
+		$oEdgesIter = new RelationTypeIterator($oNewGraph, 'Edge');
+		$aEdgeKeys = array();
+		foreach($oEdgesIter as $oEdge)
+		{
+			$sSourceId =  $oEdge->GetSourceNode()->GetId();
+			$sSinkId = $oEdge->GetSinkNode()->GetId();
+			if ($sSourceId == $sSinkId)
+			{
+				// Remove self referring edges
+				$oNewGraph->_RemoveEdge($oEdge);
+			}
+			else
+			{
+				$sKey = $sSourceId.'//'.$sSinkId;
+				if (array_key_exists($sKey, $aEdgeKeys))
+				{
+					// Remove duplicate edges
+					$oNewGraph->_RemoveEdge($oEdge);
+				}
+				else
+				{
+					$aEdgeKeys[$sKey] = true;
+				}
+			}
+		}
+		
+		$iNbGrouping = 1;
+		//for($iter=0; $iter<$iNbGrouping; $iter++)
+		{
+			$oNodesIter = new RelationTypeIterator($oNewGraph, 'Node');
+			foreach($oNodesIter as $oNode)
+			{
+				if ($oNode->GetProperty('source'))
+				{
+					$oNode->GroupSimilarNeighbours($oNewGraph, $iGroupingThreshold, true, true);
+				}
+			}
+		}
+		
+		// Remove duplicate edges between two nodes
+		$oEdgesIter = new RelationTypeIterator($oNewGraph, 'Edge');
+		$aEdgeKeys = array();
+		foreach($oEdgesIter as $oEdge)
+		{
+			$sSourceId =  $oEdge->GetSourceNode()->GetId();
+			$sSinkId = $oEdge->GetSinkNode()->GetId();
+			if ($sSourceId == $sSinkId)
+			{
+				// Remove self referring edges
+				$oNewGraph->_RemoveEdge($oEdge);
+			}
+			else
+			{
+				$sKey = $sSourceId.'//'.$sSinkId;
+				if (array_key_exists($sKey, $aEdgeKeys))
+				{
+					// Remove duplicate edges
+					$oNewGraph->_RemoveEdge($oEdge);
+				}
+				else
+				{
+					$aEdgeKeys[$sKey] = true;
+				}
+			}
+		}
+
+		return $oNewGraph;
+	}
+	
+	public function InitOnGrid()
+	{
+		$iDist = 125;
+		$aAllNodes = $this->_GetNodes();
+		$iSide = ceil(sqrt(count($aAllNodes)));
+		$xPos = 0;
+		$yPos = 0;
+		$idx = 0;
+		foreach($aAllNodes as $oNode)
+		{
+			$xPos += $iDist;
+			if (($idx % $iSide) == 0)
+			{
+				$xPos = 0;
+				$yPos += $iDist;
+			}
+			
+			$oNode->x = $xPos;
+			$oNode->y = $yPos;
+			
+			$idx++;
+		}
+		
+	}
+	
+	public function InitFromGraphviz()
+	{
+		$sDot = $this->DumpAsXDot();
+		$sDot = preg_replace('/.*label=.*,/', '', $sDot); // Get rid of label lines since they may contain weird characters than can break the split and pattern matching below
+		
+		$aChunks = explode(";", $sDot);
+		foreach($aChunks as $sChunk)
+		{
+			//echo "<p>$sChunk</p>";
+			if(preg_match('/"([^"]+)".+pos="([0-9\\.]+),([0-9\\.]+)"/ms', $sChunk, $aMatches))
+			{
+				$sId = $aMatches[1];
+				$xPos = $aMatches[2];
+				$yPos = $aMatches[3];
+				
+				$oNode = $this->GetNode($sId);
+				$oNode->x = (float)$xPos;
+				$oNode->y = (float)$yPos;
+				
+				//echo "<p>$sId at $xPos,$yPos</p>";
+			}
+			else
+			{
+				//echo "<p>No match</p>";
+			}
+		}
+	}
+	
+	public function BruteForceLayout($iNbTicks, $sDirection = 'horizontal')
+	{
+		$iLoopTimeLimit = MetaModel::GetConfig()->Get('max_execution_time_per_loop');
+		$this->sDirection = $sDirection;
+		$this->InitForces();
+		for($i=0; $i<$iNbTicks; $i++)
+		{
+			set_time_limit($iLoopTimeLimit);
+			$this->Tick();
+		}
+	}
+	
+	protected function InitForces()
+	{
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		$i = 0;
+		foreach($oIterator as $sId => $oNode)
+		{
+			$oNode->SetProperty('ax', 0);
+			$oNode->SetProperty('ay', 0);
+			$oNode->SetProperty('vx', 0);
+			$oNode->SetProperty('vy', 0);
+			$i++;
+		}
+	}
+	
+	protected function ComputeAcceleration()
+	{
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		foreach($oIterator as  $idx => $oNode)
+		{
+			$sNodeId = $oNode->GetId();
+			
+			$fx = 0;
+			$fy = 0;
+			$K = 0.6;
+			$Q = 0.3;
+			
+			if ($oNode->GetProperty('source'))
+			{
+				switch($this->sDirection)
+				{
+					case 'horizontal':
+						$fx -= 30;
+						break;
+							
+					case 'vertical':
+						$fy -= 30;
+						break;
+							
+					default:
+						// No gravity
+				}				
+			}
+			else
+			{
+				switch($this->sDirection)
+				{
+					case 'horizontal':
+					$fx += 30;
+					break;
+					
+					case 'vertical':
+					$fy += 30;
+					break;
+					
+					default:
+					// No gravity
+				}
+			}
+			
+//echo "<p>ComputeAcceleration - $sNodeId</p>\n";
+				
+			$oIter2 = new RelationTypeIterator($this, 'Edge');
+			foreach($oIter2 as $sEdgeId => $oEdge)
+			{
+				$oSource = $oEdge->GetSourceNode();
+				$oSink = $oEdge->GetSinkNode();
+				
+//echo "<p>$sEdgeId ".$oSource->GetId()." -> ".$oSink->GetId()."</p>\n";
+				
+				if ($oSource->GetId() === $sNodeId)
+				{
+					$fx += -$K * ($oSource->x - $oSink->x);
+					$fy += -$K * ($oSource->y - $oSink->y);
+//echo "<p>$sEdgeId Sink - F($fx, $fy)</p>\n";
+				}
+				else if ($oSink->GetId() === $sNodeId)
+				{
+					$fx += -$K * ($oSink->x - $oSource->x);
+					$fy += -$K * ($oSink->y - $oSource->y);
+//echo "<p>$sEdgeId Source - F($fx, $fy)</p>\n";
+				}
+				// Else do nothing for this node, it's not connected via this edge
+			}
+			$oIter3 = new RelationTypeIterator($this, 'Node');
+			foreach($oIter3 as $idx2 => $oOtherNode)
+			{
+				$sOtherId = $oOtherNode->GetId();
+				if ($sOtherId !== $sNodeId)
+				{
+					$d2 = $oOtherNode->Distance2($oNode) / (60*60);
+					if ($d2 < 15)
+					{
+						$dfx = 	-$Q * ($oOtherNode->x - $oNode->x) / $d2;
+						$dfy = 	-$Q * ($oOtherNode->y - $oNode->y) / $d2;
+						
+						$fx += $dfx;
+						$fy += $dfy;
+					}
+					
+//echo "<p>Electrostatic: $sOtherId d2: $d2 F($dfx, $dfy)</p>\n";
+
+				}
+			}
+//echo "<p>total forces: $sNodeId d2: $d2 F($fx, $fy)</p>\n";
+			$oNode->SetProperty('ax', $fx);
+			$oNode->SetProperty('ay', $fy);
+		}		
+	}
+	
+	protected function Tick()
+	{
+		$dt = 0.1;
+		$attenuation = 0.8;
+		$M = 1;
+		
+		$this->ComputeAcceleration();
+		
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		foreach($oIterator as $sId => $oNode)
+		{
+			$vx = $attenuation * $oNode->GetProperty('vx') + $M * $oNode->GetProperty('ax');
+			$vy = $attenuation * $oNode->GetProperty('vy') + $M * $oNode->GetProperty('ay');
+			
+			$oNode->x += $dt * $vx;
+			$oNode->y += $dt * $vy;
+
+			$oNode->SetProperty('vx', $vx);
+			$oNode->SetProperty('vy', $vy);
+//echo "<p>$sId - V($vx, $vy)</p>\n";
+		}
+	}
+	
+	public function GetBoundingBox()
+	{
+		$xMin = null;
+		$xMax = null;
+		$yMin = null;
+		$yMax = null;
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		foreach($oIterator as $sId => $oNode)
+		{
+			if ($xMin === null) // First element in the loop
+			{
+				$xMin = $oNode->x - $oNode->GetWidth();
+				$xMax = $oNode->x + $oNode->GetWidth();
+				$yMin = $oNode->y - $oNode->GetHeight();
+				$yMax = $oNode->y + $oNode->GetHeight();
+			}
+			else
+			{
+				$xMin = min($xMin, $oNode->x - $oNode->GetWidth() / 2);
+				$xMax = max($xMax, $oNode->x + $oNode->GetWidth() / 2);
+				$yMin = min($yMin, $oNode->y - $oNode->GetHeight() / 2);
+				$yMax = max($yMax, $oNode->y + $oNode->GetHeight() / 2);
+			}
+		}
+		
+		return array('xmin' => $xMin, 'xmax' => $xMax, 'ymin' => $yMin, 'ymax' => $yMax);
+	}
+	
+	function Translate($dx, $dy)
+	{
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		foreach($oIterator as $sId => $oNode)
+		{
+			$oNode->x += $dx;
+			$oNode->y += $dy;
+		}		
+	}
+	
+	function RenderAsRaphael(WebPage $oP, $sId = null, $bContinue = false)
+	{
+		if ($sId == null)
+		{
+			$sId = 'graph';
+		}
+		$aBB = $this->GetBoundingBox();
+		$oP->add('<div id="'.$sId.'" class="simple-graph"></div>');
+		$oP->add_ready_script("var oGraph = $('#$sId').simple_graph({xmin: {$aBB['xmin']}, xmax: {$aBB['xmax']}, ymin: {$aBB['ymin']}, ymax: {$aBB['ymax']} });");
+		
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		foreach($oIterator as $sId => $oNode)
+		{
+			$aNode = $oNode->GetForRaphael();
+			$sJSNode = json_encode($aNode);
+			$oP->add_ready_script("oGraph.simple_graph('add_node', $sJSNode);");
+		}
+		$oIterator = new RelationTypeIterator($this, 'Edge');
+		foreach($oIterator as $sId => $oEdge)
+		{
+			$aEdge = array();
+			$aEdge['id'] = $oEdge->GetId();
+			$aEdge['source_node_id'] = $oEdge->GetSourceNode()->GetId();
+			$aEdge['sink_node_id'] = $oEdge->GetSinkNode()->GetId();
+			$fOpacity = ($oEdge->GetSinkNode()->GetProperty('is_reached') && $oEdge->GetSourceNode()->GetProperty('is_reached') ? 1 : 0.2);
+			$aEdge['attr'] = array('opacity' => $fOpacity, 'stroke' => '#000');
+			$sJSEdge = json_encode($aEdge);
+			$oP->add_ready_script("oGraph.simple_graph('add_edge', $sJSEdge);");
+		}
+		
+		$oP->add_ready_script("oGraph.simple_graph('draw');");
+	}
+
+	function RenderAsPDF(WebPage $oP, $sTitle = 'Untitled', $sPageFormat = 'A4', $sPageOrientation = 'P')
+	{
+		require_once(APPROOT.'lib/tcpdf/tcpdf.php');
+		$oPdf = new TCPDF($sPageOrientation, 'mm', $sPageFormat, true, 'UTF-8', false);
+		
+		// set document information
+		$oPdf->SetCreator(PDF_CREATOR);
+		$oPdf->SetAuthor('iTop');
+		$oPdf->SetTitle($sTitle);
+		
+		$oPdf->setFontSubsetting(true);
+		
+		// Set font
+		// dejavusans is a UTF-8 Unicode font, if you only need to
+		// print standard ASCII chars, you can use core fonts like
+		// helvetica or times to reduce file size.
+		$oPdf->SetFont('dejavusans', '', 14, '', true);
+		
+		// set auto page breaks
+		$oPdf->SetAutoPageBreak(false);
+		
+		// Add a page
+		// This method has several options, check the source code documentation for more information.
+		$oPdf->AddPage();
+		
+		$aBB = $this->GetBoundingBox();
+		//$this->Translate(-$aBB['xmin'], -$aBB['ymin']);
+		if ($sPageOrientation == 'P')
+		{
+			// Portrait mode
+			$fHMargin = 10; // mm
+			$fVMargin = 15; // mm
+		}
+		else
+		{
+			// Landscape mode
+			$fHMargin = 15; // mm
+			$fVMargin = 10; // mm
+		}
+		
+		$fPageW = $oPdf->getPageWidth() - 2 * $fHMargin;
+		$fPageH = $oPdf->getPageHeight() - 2 * $fVMargin;
+		
+		$w = $aBB['xmax'] - $aBB['xmin']; 
+		$h = $aBB['ymax'] - $aBB['ymin'] + 10; // Extra space for the labels which may appear "below" the icons
+		
+		$fScale = min($fPageW / $w, $fPageH / $h);
+		$dx = ($fPageW - $fScale * $w) / 2;
+		$dy = ($fPageH - $fScale * $h) / 2;
+		
+		$this->Translate(($fHMargin + $dx)/$fScale, ($fVMargin + $dy)/$fScale);
+		
+		$oIterator = new RelationTypeIterator($this, 'Edge');
+		foreach($oIterator as $sId => $oEdge)
+		{
+			$oEdge->RenderAsPDF($oPdf, $fScale);
+		}
+
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		foreach($oIterator as $sId => $oNode)
+		{
+			$oNode->RenderAsPDF($oPdf, $fScale);
+		}
+		
+		$oP->add($oPdf->Output('iTop.pdf', 'S'));	
+	}
+	
+}

+ 63 - 8
core/metamodel.class.php

@@ -231,6 +231,7 @@ abstract class MetaModel
 	}
 
 	private static $m_oConfig = null;
+	protected static $m_aModulesParameters = array();
 
 	private static $m_bSkipCheckToWrite = false;
 	private static $m_bSkipCheckExtKeys = false;
@@ -1102,6 +1103,11 @@ abstract class MetaModel
 	//
 	private static $m_aRelationInfos = array(); // array of ("relcode" => various info on the list, common to every classes)
 
+	/**
+	 * TO BE DEPRECATED: use EnumRelationsEx instead
+	 * @param string $sClass
+	 * @return multitype:string unknown |Ambigous <string, multitype:>
+	 */
 	public static function EnumRelations($sClass = '')
 	{
 		$aResult = array_keys(self::$m_aRelationInfos);
@@ -1144,23 +1150,54 @@ abstract class MetaModel
 		return $aResult;
 	}
 
+	public static function EnumRelationsEx($sClass)
+	{
+		$aRelationInfo = array_keys(self::$m_aRelationInfos);
+		// Return only the relations that have a meaning (i.e. for which at least one query is defined)
+		// for the specified class
+		$aClassRelations = array();
+		foreach($aRelationInfo as $sRelCode)
+		{
+			$aQueriesDown = self::EnumRelationQueries($sClass, $sRelCode, true /* Down */);
+			if (count($aQueriesDown) > 0)
+			{
+				$aClassRelations[$sRelCode]['down'] = self::GetRelationLabel($sRelCode, true);
+			}
+				
+			$aQueriesUp = self::EnumRelationQueries($sClass, $sRelCode, false /* Up */);
+			if (count($aQueriesUp) > 0)
+			{
+				$aClassRelations[$sRelCode]['up'] = self::GetRelationLabel($sRelCode, false);
+			}
+		}
+	
+		return $aClassRelations;
+	}
+	
 	final static public function GetRelationDescription($sRelCode)
 	{
 		return Dict::S("Relation:$sRelCode/Description");
 	}
 
-	final static public function GetRelationLabel($sRelCode)
+	final static public function GetRelationLabel($sRelCode, $bDown = true)
 	{
-		// The legacy convention is confusing with regard to the way we have conceptualized the relations:
-		// In the former representation, the main stream was named after "up"
-		// Now, the relation from A to B says that something is transmitted from A to B, thus going DOWNstream as described in a petri net.
-		$sKey = "Relation:$sRelCode/DownStream";
-		$sLegacy = Dict::S("Relation:$sRelCode/VerbUp", $sKey);
+		if ($bDown)
+		{
+			// The legacy convention is confusing with regard to the way we have conceptualized the relations:
+			// In the former representation, the main stream was named after "up"
+			// Now, the relation from A to B says that something is transmitted from A to B, thus going DOWNstream as described in a petri net.
+			$sKey = "Relation:$sRelCode/DownStream";
+			$sLegacy = Dict::S("Relation:$sRelCode/VerbUp", $sKey);
+		}
+		else
+		{
+			$sKey = "Relation:$sRelCode/UpStream";
+			$sLegacy = Dict::S("Relation:$sRelCode/VerbDown", $sKey);
+		}
 		$sRet = Dict::S($sKey, $sLegacy);
 		return $sRet;
 	}
 
-
 	protected static function ComputeRelationQueries($sRelCode)
 	{
 		$bHasLegacy = false;
@@ -1339,6 +1376,10 @@ abstract class MetaModel
 				{
 					foreach ($aQueries[$sClass]['up'] as $sNeighbourId => $aNeighbourData)
 					{
+						if (!array_key_exists('_legacy_', $aNeighbourData))
+						{
+							continue;
+						}
 						if (!$aNeighbourData['_legacy_']) continue; // Skip modern definitions
 
 						$sLocalClass = $aNeighbourData['sToClass'];
@@ -1363,7 +1404,7 @@ abstract class MetaModel
 					$sLocalClass = $aNeighbourData['sFromClass'];
 					foreach (self::EnumChildClasses($aNeighbourData['sToClass'], ENUM_CHILD_CLASSES_ALL) as $sRemoteClass)
 					{
-						$aQueries[$sRemoteClass]['up'][$sLocalClass]['sQueryDown'] = $aNeighbourData['sQueryDown'];
+						//$aQueries[$sRemoteClass]['up'][$sLocalClass]['sQueryDown'] = $aNeighbourData['sQueryDown'];
 					}
 				}
 			}
@@ -5227,6 +5268,20 @@ abstract class MetaModel
 		return self::$m_oConfig->GetModuleSetting($sModule, $sProperty, $defaultvalue);
 	}
 
+	public static function GetModuleParameter($sModule, $sProperty, $defaultvalue = null)
+	{
+		$value = $defaultvalue;
+		if (!array_key_exists($sModule, self::$m_aModulesParameters))
+		{
+			
+		}
+		if (!self::$m_aModulesParameters[$sModule] == null)
+		{
+			$value = self::$m_aModulesParameters[$sModule]->Get($sProperty, $defaultvalue);
+		}
+		return $value;
+	}
+	
 	public static function GetConfig()
 	{
 		return self::$m_oConfig;

+ 212 - 4
core/simplegraph.class.inc.php

@@ -154,6 +154,24 @@ class GraphNode extends GraphElement
 	}
 	
 	/**
+	 * INTERNAL USE ONLY
+	 * @param GraphEdge $oEdge
+	 */
+	public function _RemoveIncomingEdge(GraphEdge $oEdge)
+	{
+		unset($this->aIncomingEdges[$oEdge->GetId()]);
+	}
+
+	/**
+	 * INTERNAL USE ONLY
+	 * @param GraphEdge $oEdge
+	 */
+	public function _RemoveOutgoingEdge(GraphEdge $oEdge)
+	{
+		unset($this->aOutgoingEdges[$oEdge->GetId()]);
+	}
+	
+	/**
 	 * Get the list of  all incoming edges on the current node
 	 * @return Ambigous <multitype:, GraphEdge>
 	 */
@@ -171,6 +189,38 @@ class GraphNode extends GraphElement
 		return $this->aOutgoingEdges;
 	}
 	
+	/**
+	 * Flood fill the chart with the given value for the specified property
+	 * @param string $sPropName The name of the property to set
+	 * @param mixed $value Teh value to set in the property
+	 * @param bool $bFloodDown Whether or not to fill in the downstream direction
+	 * @param bool $bFloodUp Whether or not to fill in the upstream direction
+	 */
+	public function FloodProperty($sPropName, $value, $bFloodDown, $bFloodUp)
+	{
+		if ($this->GetProperty($sPropName, null) == null)
+		{
+			// Property not already set, let's do it
+			$this->SetProperty($sPropName, $value);
+			if ($bFloodDown)
+			{
+				foreach($this->GetOutgoingEdges() as $oEdge)
+				{
+					$oEdge->SetProperty($sPropName, $value);
+					$oEdge->GetSinkNode()->FloodProperty($sPropName, $value, $bFloodDown, $bFloodUp);
+				}
+			}
+			if ($bFloodUp)
+			{
+				foreach($this->GetIncomingEdges() as $oEdge)
+				{
+					$oEdge->SetProperty($sPropName, $value);
+					$oEdge->GetSourceNode()->FloodProperty($sPropName, $value, $bFloodDown, $bFloodUp);
+				}
+			}
+		}
+	}
+	
 }
 
 /**
@@ -263,12 +313,31 @@ class SimpleGraph
 	 */
 	public function _AddNode(GraphNode $oNode)
 	{
-		if (array_key_exists($oNode->GetId(), $this->aNodes)) throw new SimpleGraphException('Cannot add node (id='.$oNode->GetId().') to the graph. A node with the same id already exists inthe graph.');
+		if (array_key_exists($oNode->GetId(), $this->aNodes)) throw new SimpleGraphException('Cannot add node (id='.$oNode->GetId().') to the graph. A node with the same id already exists in the graph.');
 		
 		$this->aNodes[$oNode->GetId()] = $oNode;
 	}
 	
 	/**
+	 * INTERNAL USE ONLY
+	 * @return Ambigous <multitype:, GraphNode>
+	 */
+	public function _RemoveNode(GraphNode $oNode)
+	{
+		if (!array_key_exists($oNode->GetId(), $this->aNodes)) throw new SimpleGraphException('Cannot remove the node (id='.$oNode->GetId().') from the graph. The node was not found in the graph.');
+		
+		foreach($oNode->GetOutgoingEdges() as $oEdge)
+		{
+			$this->_RemoveEdge($oEdge);
+		}
+		foreach($oNode->GetIncomingEdges() as $oEdge)
+		{
+			$this->_RemoveEdge($oEdge);
+		}
+		unset($this->aNodes[$oNode->GetId()]);
+	}
+	
+	/**
 	 * Get the node identified by $sId or null if not found
 	 * @param string $sId
 	 * @return NULL | GraphNode
@@ -295,7 +364,7 @@ class SimpleGraph
 	 */
 	public function _AddEdge(GraphEdge $oEdge)
 	{
-		if (array_key_exists($oEdge->GetId(), $this->aEdges)) throw new SimpleGraphException('Cannot add edge (id='.$oEdge->GetId().') to the graph. An edge with the same id already exists inthe graph.');
+		if (array_key_exists($oEdge->GetId(), $this->aEdges)) throw new SimpleGraphException('Cannot add edge (id='.$oEdge->GetId().') to the graph. An edge with the same id already exists in the graph.');
 		
 		$this->aEdges[$oEdge->GetId()] = $oEdge;
 		$oEdge->GetSourceNode()->_AddOutgoingEdge($oEdge);
@@ -303,6 +372,21 @@ class SimpleGraph
 	}
 	
 	/**
+	 * INTERNAL USE ONLY
+	 * @param GraphEdge $oEdge
+	 * @throws SimpleGraphException
+	 */
+	public function _RemoveEdge(GraphEdge $oEdge)
+	{
+		if (!array_key_exists($oEdge->GetId(), $this->aEdges)) throw new SimpleGraphException('Cannot remove edge (id='.$oEdge->GetId().') from the graph. The edge was not found.');
+		
+		$oEdge->GetSourceNode()->_RemoveOutgoingEdge($oEdge);
+		$oEdge->GetSinkNode()->_RemoveIncomingEdge($oEdge);
+		
+		unset($this->aEdges[$oEdge->GetId()]);
+	}
+	
+	/**
 	 * Get the edge indentified by $sId or null if not found
 	 * @param string $sId
 	 * @return NULL | GraphEdge
@@ -333,8 +417,9 @@ class SimpleGraph
 digraph finite_state_machine {
 graph [bgcolor = "transparent"];
 rankdir=LR;
-size="30,30"
-node [ fontname=Verdana style=filled fillcolor="#ffffcc" ];
+size="30,30";
+fontsize=8.0;
+node [ fontname=Verdana style=filled fillcolor="#ffffcc" fontsize=8.0 ];
 edge [ fontname=Verdana ];
 
 EOF
@@ -414,6 +499,62 @@ EOF
 	}
 	
 	/**
+	 * Get the description of the graph as an embedded PNG image (using a data: url) as
+	 * generated by graphviz (requires graphviz to be installed on the machine and the path to
+	 * dot/dot.exe to be configured in the iTop configuration file)
+	 * Note: the function creates temporary files in APPROOT/data/tmp
+	 * @return string
+	 */
+	public function DumpAsXDot()
+	{
+		$sDotExecutable = MetaModel::GetConfig()->Get('graphviz_path');
+		if (file_exists($sDotExecutable))
+		{
+			// create the file with Graphviz
+			if (!is_dir(APPROOT."data"))
+			{
+				@mkdir(APPROOT."data");
+			}
+			if (!is_dir(APPROOT."data/tmp"))
+			{
+				@mkdir(APPROOT."data/tmp");
+			}
+			$sXdotFilePath = tempnam(APPROOT."data/tmp", 'xdot-');
+			$sDotDescription = $this->GetDotDescription();
+			$sDotFilePath = tempnam(APPROOT."data/tmp", 'dot-');
+	
+			$rFile = @fopen($sDotFilePath, "w");
+			@fwrite($rFile, $sDotDescription);
+			@fclose($rFile);
+			$aOutput = array();
+			$CommandLine = "\"$sDotExecutable\" -v -Tdot < $sDotFilePath -o$sXdotFilePath 2>&1";
+	
+			exec($CommandLine, $aOutput, $iRetCode);
+			if ($iRetCode != 0)
+			{
+				$sHtml = '';
+				$sHtml .= "<p><b>Error:</b></p>";
+				$sHtml .= "<p>The command: <pre>$CommandLine</pre> returned $iRetCode</p>";
+				$sHtml .= "<p>The output of the command is:<pre>\n".implode("\n", $aOutput)."</pre></p>";
+				$sHtml .= "<hr>";
+				$sHtml .= "<p>Content of the '".basename($sDotFilePath)."' file:<pre>\n$sDotDescription</pre>";
+			}
+			else
+			{
+				$sHtml = '<pre>'.file_get_contents($sXdotFilePath).'</pre>';
+				@unlink($sImageFilePath);
+			}
+			@unlink($sXdotFilePath);
+		}
+		else
+		{
+			throw new Exception('graphviz not found (executable path: '.$sDotExecutable.')');
+		}
+		return $sHtml;
+	}
+	
+	
+	/**
 	 * Get the description of the graph as some HTML text
 	 * @return string
 	 */
@@ -456,6 +597,73 @@ EOF
 		}
 		return $sHtml;		
 	}
+	
+	/**
+	 * Split the graph in a array of non connected subgraphs
+	 * @return multitype:SimpleGraph unknown
+	 */
+	public function GetSubgraphs()
+	{
+		$iNbColors = 0;
+		$aResult = array();
+		$oIterator = new RelationTypeIterator($this, 'Node');
+		foreach($oIterator as $oNode)
+		{
+			$iPrevColor = $oNode->GetProperty('color', null);
+			
+			if ($iPrevColor == null)
+			{
+				$iNbColors++; // Start a new color
+				$oNode->FloodProperty('color', $iNbColors, true, true);
+			}
+		}
+		if ($iNbColors == 1)
+		{
+			// Everything is connected together, only one subgraph
+			$aResult[] = $this;
+		}
+		else
+		{
+			// Let's reconstruct each separate graph
+			$sClass = get_class($this);
+			for($i = 1; $i <= $iNbColors; $i++)
+			{
+				$aResult[$i] = new $sClass();
+			}
+			
+			foreach($oIterator as $oNode)
+			{
+				$iNodeColor = $oNode->GetProperty('color');
+				$aResult[$iNodeColor]->_AddNode($oNode);
+			}
+			
+			$oIter2 = new RelationTypeIterator($this, 'Edge');
+			foreach($oIter2 as $oEdge)
+			{
+				$iEdgeColor = $oEdge->GetProperty('color');
+				$aResult[$iEdgeColor]->_AddEdge($oEdge);
+			}
+		}
+		return $aResult;	
+	}
+	
+	/**
+	 * Merge back to subgraphs into one
+	 * @param SimpleGraph $oGraph
+	 */
+	public function Merge(SimpleGraph $oGraph)
+	{
+		$oIter1 = new RelationTypeIterator($oGraph, 'Node');
+		foreach($oIter1 as $oNode)
+		{
+			$this->_AddNode($oNode);
+		}
+		$oIter2 = new RelationTypeIterator($oGraph, 'Edge');
+		foreach($oIter2 as $oEdge)
+		{
+			$this->_AddEdge($oEdge);
+		}
+	}
 }
 
 /**

+ 3 - 0
css/light-grey.css

@@ -1419,4 +1419,7 @@ div.ui-dialog-header {
 .form_field_error {
 	border: 1px solid #933;
 	background: #fcc;
+}
+.simple-graph {
+	background: #fff;
 }

+ 574 - 0
js/fraphael.js

@@ -0,0 +1,574 @@
+/**
+ * FRaphael
+ * 	An extension for Raphael.js to make it easier to work with Filter Effects
+ * 
+ * Copyright © 2013 Chris Scott <chris.scott@factmint.com>
+ * Delivered with and licensed under the MIT licence
+ * 
+ */
+
+// Create the global FRaphael object
+(function(scope) {
+	var	version = "0.0.1",
+	license = "MIT";
+	
+	var	ns = "http://www.w3.org/2000/svg",
+	idCounter = 0;
+	
+	var FR = {
+		// Object prototype for a filter
+		Filter: function(id) {
+			if (id == undefined) {
+				id = "filter-" + idCounter++;
+				while(FR.filters[id] != undefined) {
+					id = "filter-" + idCounter++;
+				}
+			}
+			
+			if (FR.filters[id] != undefined) {
+				throw "A filter with id " + id + " already exists";
+			}
+			
+			this.element = document.createElementNS(ns, "filter");
+			this.element.setAttribute("id", id);
+			this.element.setAttribute("x", "-25%");
+			this.element.setAttribute("y", "-25%");
+			this.element.setAttribute("width", "150%");
+			this.element.setAttribute("height", "150%");
+			
+			this.lastFEResult = null;
+			
+			FR.filters[id] = this;
+			this.id = id;
+		},
+ 
+		// Object prototype for an effect
+		FilterEffect: function(type, attributes) {
+			this.element = document.createElementNS(ns, type);
+			for (var key in attributes) {
+				this.element.setAttribute(key, attributes[key]);
+			}
+		},
+ 
+		// Return the filter applied to an element or a new filter if none are currently applied
+		getFilter: function(element) {
+			var filterId = element.data("filterId");
+			var filter = null;
+			
+			if (filterId == undefined) {
+				filterId = "element-filter-" + element.id;
+				filter = element.paper.createFilter(filterId);
+				element.filter(filterId);
+			} else {
+				filter = FR.filters[filterId];
+			}
+			
+			return filter;
+		},
+ 
+		// maintain a list of filters by id
+		filters: {}
+	};
+	
+	FR.Filter.prototype = {
+		addEffect: function(type, attributes, children) {
+			var effect = new FR.FilterEffect(type, attributes);
+			
+			if (children) {
+				if (children instanceof Array) {
+					for (var x in children) {
+						if (!children.hasOwnProperty(x)) continue;
+
+						effect.element.appendChild(children[x].element);
+					}
+				} else {
+					effect.element.appendChild(children.element);
+				}
+			}
+			
+			this.element.appendChild(effect.element);
+			
+			return this;
+		},
+ 
+		chainEffect: function(type, attributes, children) {
+			if (attributes == undefined) {
+				attributes = {};
+			}
+ 
+			var inId;
+			var outId;
+			if (attributes.in == undefined) {
+				inId = this.getLastResult();
+			} else {
+				inId = attributes.in;
+			}
+			if (attributes.result == undefined) {
+				outId = idCounter++;
+			} else {
+				outId = attributes.result;
+			}
+			
+			this.lastFEResult = outId;
+			
+			attributes.in = inId;
+			attributes.result = outId;
+			
+			this.addEffect(type, attributes, children);
+			
+			return this;
+		},
+ 
+		getLastResult: function() {
+			return (this.lastFEResult == undefined) ? "SourceGraphic" : this.lastFEResult;
+		},
+ 
+		merge: function(in1, in2, attributes) {
+			var mergeNode1 = new FR.FilterEffect("feMergeNode", {
+				in: in1
+			});
+			var mergeNode2 = new FR.FilterEffect("feMergeNode", {
+				in: in2
+			});
+			
+			this.chainEffect("feMerge", attributes, [mergeNode1, mergeNode2]);
+			
+			return this;
+		},
+ 
+		compose: function(in1, in2, operator, attributes) {
+			if (attributes == undefined) {
+				attributes = {};
+			}
+			
+			if (operator == undefined) {
+				operator = "over";
+			}
+			
+			attributes.in = in1;
+			attributes.in2 = in2;
+			attributes.operator = operator;
+			
+			this.chainEffect("feComposite", attributes);
+			
+			return this;
+		},
+ 
+		arithmeticCompose: function(in1, in2, k1, k2, k3, k4) {
+			if (k1 == undefined) {
+				k1 = 0;
+			}
+			if (k2 == undefined) {
+				k2 = 0;
+			}
+			if (k3 == undefined) {
+				k3 = 0;
+			}
+			if (k4 == undefined) {
+				k4 = 0;
+			}
+			
+			this.compose(in1, in2, "arithmetic", {
+				k1: k1,
+				k2: k2,
+				k3: k3,
+				k4: k4
+			});
+			
+			return this;
+		},
+ 
+		addBlur: function(stdDeviation, attributes) {
+			if (!stdDeviation) {
+				throw "Standard deviation is required to perform a blur filter";
+			}
+			
+			if (attributes == undefined) {
+				attributes = {};
+			}
+			attributes.stdDeviation = stdDeviation;
+			
+			this.chainEffect("feGaussianBlur", attributes);
+			
+			return this;
+		},
+ 
+		addOffset: function(dx, dy, attributes) {
+			if (dx == undefined | dy == undefined) {
+				throw "dx and dy values are required to perform an offset FE";
+			}
+			
+			if (attributes == undefined) {
+				attributes = {};
+			}
+			attributes.dx = dx;
+			attributes.dy = dy;
+			
+			this.chainEffect("feOffset", attributes);
+			
+			return this;
+		},
+ 
+		addLighting: function(x, y, z, color, type, attributes) {
+			if (x == undefined | y == undefined | z == undefined) {
+				throw "Three co-ordinates are required to create a light source";
+			}
+			
+			var previousResult = this.getLastResult();
+			
+			var id = idCounter++;
+			
+			if (attributes == undefined) {
+				attributes = {};
+			}
+			
+			attributes.result = id;
+			if (color != undefined) {
+				attributes["lighting-color"] = color;
+			}
+			
+			if (type == undefined || type == "diffuse") {
+				type = "feDiffuseLighting";
+			} else if (type == "specular") {
+				type = "feSpecularLighting";
+			}
+			
+			var lightSource = new FR.FilterEffect("fePointLight", {
+				x: x,
+				y: y,
+				z: z
+			});
+			
+			this.chainEffect(type, attributes, lightSource).arithmeticCompose(previousResult, id, 3, 0.2, 0, 0);
+			
+			return this;
+		},
+ 
+		addShiftToColor: function(color, moveBy, attributes) {
+			if (color == undefined) {
+				throw "A colour string is a required argument to create a colorMatrix";
+			}
+			if (moveBy == undefined) {
+				moveBy = 0.5;
+			}
+			
+			var remainingColor = 1 - moveBy, x = remainingColor;
+			
+			if (attributes == undefined) {
+				attributes = {};
+			}
+			
+			var colorObject = Raphael.color(color);
+			var	r = colorObject.r * moveBy / 255,
+			g = colorObject.g * moveBy / 255,
+			b = colorObject.b * moveBy / 255;
+			
+			/**
+			 * r'	x 0 0 0 r		r 
+			 * g'	0 x 0 0 g		g
+			 * b' =	0 0 x 0 b	.	b
+			 * a'	0 0 0 1 0		o
+			 * 1					1
+			 */
+			attributes.values = x + " 0 0 0 " + r + " 0 " + x + " 0 0 " + g + " 0 0 " + x + " 0 " + b + " 0 0 0 1 0 ";
+			
+			this.chainEffect("feColorMatrix", attributes);
+			
+			return this;
+		},
+ 
+		addRecolor: function(color, opacity, attributes) {
+			if (color == undefined) {
+				throw "A colour string is a required argument to create a colorMatrix";
+			}
+			if (opacity == undefined) {
+				opacity = 1;
+			}
+			
+			if (attributes == undefined) {
+				attributes = {};
+			}
+			
+			var colorObject = Raphael.color(color);
+			var	r = colorObject.r / 255,
+			g = colorObject.g / 255,
+			b = colorObject.b / 255;
+			
+			/**
+			 * r'	0 0 0 0 r		r 
+			 * g'	0 0 0 0 g		g
+			 * b' =	0 0 0 0 b	.	b
+			 * a'	0 0 0 a 0		a
+			 * 1					1
+			 */
+			attributes.values = "0 0 0 0 " + r + " 0 0 0 0 " + g + " 0 0 0 0 " + b + " 0 0 0 " + opacity + " 0 ";
+			
+			this.chainEffect("feColorMatrix", attributes);
+			
+			return this;
+		},
+	
+		addDesaturate: function(saturation, attributes) {
+			if (saturation == undefined) {
+				saturnation = 0;
+			}
+
+			if (attributes == undefined) {
+				attributes = {};
+			}
+
+            attributes.values = saturation;
+			attributes.type = "saturate";
+
+			this.chainEffect("feColorMatrix", attributes);
+
+			return this;
+		},
+ 
+		addConvolveMatrix: function(matrix, attributes) {
+			if (matrix == undefined) {
+				throw "A matrix (usually 9 numbers) must be provided to apply a convolve matrix transform";
+			}
+			
+			if (attributes == undefined) {
+				attributes = {};
+			}
+			
+			attributes.kernelMatrix = matrix;
+			
+			this.chainEffect("feConvolveMatrix", attributes);
+			
+			return this;
+		},
+ 
+		createShadow: function(dx, dy, blur, opacity, color) {
+			if (dx == undefined) {
+				throw "dx is required for the shadow effect";
+			}
+			if (dy == undefined) {
+				throw "dy is required for the shadow effect";
+			}
+			if (blur == undefined) {
+				throw "blur (stdDeviation) is required for the shadow effect";
+			}
+			
+			if (opacity == undefined) {
+				opacity = 0.6;
+			}
+			
+			var previousResult = this.getLastResult();
+			
+			if (color == undefined) {
+				color = "#000000";
+			}
+			
+			this.addOffset(dx, dy, {
+				in: "SourceAlpha"
+			});
+			
+			this.addRecolor(color, opacity);
+			
+			this.addBlur(blur);
+			
+			this.merge(this.getLastResult(), previousResult);
+			
+			return this;
+		},
+ 
+		createEmboss: function(height, x, y, z) {
+			if (height == undefined) {
+				height = 2;
+			}
+			if (x == undefined) {
+				x = -1000;
+			}
+			if (y == undefined) {
+				y = -5000;
+			}
+			if (z == undefined) {
+				z = 300;
+			}
+			
+			// Create the highlight
+			
+			this.addOffset(height * x / (x + y), height * y / (x + y), {
+				in: "SourceAlpha"
+			});
+			
+			this.addBlur(height * 0.5);
+						
+			var whiteLightSource = new FR.FilterEffect("fePointLight", {
+				x: x,
+				y: y,
+				z: z
+			});
+			
+			this.chainEffect("feSpecularLighting", {
+				surfaceScale: height,
+				specularConstant: 0.8,
+				specularExponent: 15
+			}, whiteLightSource);
+			
+			this.compose(this.getLastResult(), "SourceAlpha", "in");
+			var whiteLight = this.getLastResult();
+			
+			// Create the lowlight
+			
+			this.addOffset(height * -1 * x / (x + y), height * -1 * y / (x + y), {
+				in: "SourceAlpha"
+			});
+			
+			this.addBlur(height * 0.5);
+						
+			var darkLightSource = new FR.FilterEffect("fePointLight", {
+				x: -1 * x,
+				y: -1 * y,
+				z:      z
+			});
+			
+			this.chainEffect("feSpecularLighting", {
+				surfaceScale: height,
+				specularConstant: 1.8,
+				specularExponent: 6
+			}, darkLightSource);
+			
+			this.compose(this.getLastResult(), "SourceAlpha", "in");
+			this.chainEffect("feColorMatrix", {
+				values: "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"
+			});
+			var darkLight = this.getLastResult();
+			
+			this.arithmeticCompose(whiteLight, darkLight, 0, 0.8, 0.5, 0);
+			
+			this.merge("SourceGraphic", this.getLastResult());
+						
+			return this;
+		}
+	};
+	
+	scope.FRaphael = FR;
+})(this);
+
+/**
+ * add a filter to the paper by id
+ */
+Raphael.fn.createFilter = function(id) {
+	var paper = this;
+	var filter = new FRaphael.Filter(id);
+	paper.defs.appendChild(filter.element);
+	
+	return filter;
+};
+
+/**
+ * Apply a filter to an element by id
+ */
+Raphael.el.filter = function(filter) {
+	var id = (filter instanceof FRaphael.Filter) ? filter.id : filter;
+	
+	this.node.setAttribute("filter", "url(#" + id + ")");
+	this.data("filterId", id);
+	
+	return this;
+};
+
+/**
+ * Get the current filter for an element or a new one if not
+ */
+Raphael.el.getFilter = function() {
+	return FRaphael.getFilter(this);
+};
+
+/**
+ * A shorthand method for applying blur
+ */
+Raphael.el.blur = function(stdDeviation) {
+	if (stdDeviation == undefined) {
+		stdDeviation = 3;
+	}
+	
+	this.getFilter().addBlur(stdDeviation);
+	
+	return this;
+};
+
+/**
+ * A shorthand method for applying a drop shadow
+ */
+Raphael.el.shadow = function(dx, dy, blur, opacity, color) {
+	if (dx == undefined) {
+		dx = 3;
+	}
+	if (dy == undefined) {
+		dy = 3;
+	}
+	if (blur == undefined) {
+		blur = 3;
+	}
+	
+	this.getFilter().createShadow(dx, dy, blur, opacity, color);
+	
+	return this;
+};
+
+/**
+ * A shorthand method for applying lighting
+ */
+Raphael.el.light = function(x, y, z, color, type) {
+	if (x == undefined) {
+		x = this.paper.width;
+	}
+	if (y == undefined) {
+		y = 0;
+	}
+	if (z == undefined) {
+		z = 20;
+	}
+	
+	this.getFilter().addLighting(x, y, z, color, type);
+	
+	return this;
+};
+
+/**
+ * A shorthand method for applying a colour shift
+ */
+Raphael.el.colorShift = function(color, shift) {
+	if (color == undefined) {
+		color = "black";
+	}
+	if (shift == undefined) {
+		shift = 0.5;
+	}
+	
+	this.getFilter().addShiftToColor(color, shift);
+	
+	return this;
+};
+
+/**
+ * A shorthand method for embossing
+ */
+Raphael.el.emboss = function(height) {
+	this.getFilter().createEmboss(height);
+	
+	return this;
+};
+
+/**
+ * A shorthand method for desaturating
+ */
+Raphael.el.desaturate = function(saturation) {
+	this.getFilter().addDesaturate(saturation);
+	
+	return this;
+};
+
+/**
+ * A shorthand method for complete desaturation
+ */
+Raphael.el.greyScale = function() {
+	this.getFilter().addDesaturate(0);
+	
+	return this;
+};

+ 326 - 0
js/simple_graph.js

@@ -0,0 +1,326 @@
+// jQuery UI style "widget" for displaying a graph
+
+////////////////////////////////////////////////////////////////////////////////
+//
+// graph
+//
+$(function()
+{
+	// the widget definition, where "itop" is the namespace,
+	// "dashboard" the widget name
+	$.widget( "itop.simple_graph",
+	{
+		// default options
+		options:
+		{
+			xmin: 0,
+			xmax: 0,
+			ymin: 0,
+			ymax: 0,
+			align: 'center',
+			'vertical-align': 'middle'
+		},
+	
+		// the constructor
+		_create: function()
+		{
+			var me = this;
+			this.aNodes = [];
+			this.aEdges = [];
+			this.fZoom = 1.0;
+			this.xOffset = 0;
+			this.yOffset = 0;
+			this.iTextHeight = 12;
+			//this.element.height(this.element.parent().height());
+			this.oPaper = Raphael(this.element.get(0), this.element.width(), this.element.height());
+			
+			this.auto_scale();
+
+			this.element
+			.addClass('itop-simple-graph');
+			
+			this._create_toolkit_menu();
+		},
+	
+		// called when created, and later when changing options
+		_refresh: function()
+		{
+			this.draw();
+		},
+		// events bound via _bind are removed automatically
+		// revert other modifications here
+		_destroy: function()
+		{
+			var sId = this.element.attr('id');
+			this.element
+			.removeClass('itop-simple-graph');
+			
+			$('#tk_graph'+sId).remove();
+			
+		},
+		// _setOptions is called with a hash of all options that are changing
+		_setOptions: function()
+		{
+			this._superApply(arguments);
+		},
+		// _setOption is called for each individual option that is changing
+		_setOption: function( key, value )
+		{
+			this._superApply(arguments);
+		},
+		draw: function()
+		{
+			this.oPaper.clear();
+			for(var k in this.aNodes)
+			{
+				this._draw_node(this.aNodes[k]);
+			}
+			for(var k in this.aEdges)
+			{
+				this._draw_edge(this.aEdges[k]);
+			}
+		},
+		_draw_node: function(oNode)
+		{
+			var iWidth = oNode.width;
+			var iHeight = 32;
+			var xPos = Math.round(oNode.x * this.fZoom + this.xOffset);
+			var yPos = Math.round(oNode.y * this.fZoom + this.yOffset);
+			oNode.tx = 0;
+			oNode.ty = 0;
+			switch(oNode.shape)
+			{
+				case 'disc':
+				oNode.aElements.push(this.oPaper.circle(xPos, yPos, iWidth*this.fZoom / 2).attr(oNode.disc_attr));
+				var oText = this.oPaper.text(xPos, yPos, oNode.label);
+				oText.attr(oNode.text_attr);
+				oText.transform('s'+this.fZoom);
+				oNode.aElements.push(oText);
+				break;
+					
+				case 'group':
+				oNode.aElements.push(this.oPaper.circle(xPos, yPos, iWidth*this.fZoom / 2).attr({fill: '#fff', 'stroke-width':0}));
+				oNode.aElements.push(this.oPaper.circle(xPos, yPos, iWidth*this.fZoom / 2).attr(oNode.disc_attr));
+				var xIcon = xPos - 18 * this.fZoom;
+				var yIcon = yPos - 18 * this.fZoom;
+				oNode.aElements.push(this.oPaper.image(oNode.icon_url, xIcon, yIcon, 16*this.fZoom, 16*this.fZoom).attr(oNode.icon_attr));
+				oNode.aElements.push(this.oPaper.image(oNode.icon_url, xIcon + 18*this.fZoom, yIcon, 16*this.fZoom, 16*this.fZoom).attr(oNode.icon_attr));
+				oNode.aElements.push(this.oPaper.image(oNode.icon_url, xIcon + 9*this.fZoom, yIcon + 18*this.fZoom, 16*this.fZoom, 16*this.fZoom).attr(oNode.icon_attr));
+				var oText = this.oPaper.text(xPos, yPos +2, oNode.label);
+				oText.attr(oNode.text_attr);
+				oText.transform('s'+this.fZoom);
+				var oBB = oText.getBBox();
+				var dy = iHeight/2*this.fZoom + oBB.height/2;
+				oText.remove();
+				oText = this.oPaper.text(xPos, yPos +dy +2, oNode.label);
+				oText.attr(oNode.text_attr);
+				oText.transform('s'+this.fZoom);
+				oNode.aElements.push(oText);
+				oNode.aElements.push(this.oPaper.rect( xPos - oBB.width/2 -2, yPos - oBB.height/2 + dy, oBB.width +4, oBB.height).attr({fill: '#fff', stroke: '#fff', opacity: 0.9}));
+				oText.toFront();
+				break;
+					
+				case 'icon':
+				if(Raphael.svg)
+				{
+					// the colorShift plugin works only in SVG
+					oNode.aElements.push(this.oPaper.image(oNode.icon_url, xPos - iWidth * this.fZoom/2, yPos - iHeight * this.fZoom/2, iWidth*this.fZoom, iHeight*this.fZoom).colorShift('#fff', 1));					
+				}
+				oNode.aElements.push(this.oPaper.image(oNode.icon_url, xPos - iWidth * this.fZoom/2, yPos - iHeight * this.fZoom/2, iWidth*this.fZoom, iHeight*this.fZoom).attr(oNode.icon_attr));
+				var oText = this.oPaper.text( xPos, yPos, oNode.label);
+				oText.attr(oNode.text_attr);
+				oText.transform('s'+this.fZoom);
+				var oBB = oText.getBBox();
+				var dy = iHeight/2*this.fZoom + oBB.height/2;
+				oText.remove();
+				oText = this.oPaper.text( xPos, yPos + dy, oNode.label);
+				oText.attr(oNode.text_attr);
+				oText.transform('s'+this.fZoom);
+				oNode.aElements.push(oText);
+				oNode.aElements.push(this.oPaper.rect( xPos - oBB.width/2 -2, yPos - oBB.height/2 + dy, oBB.width +4, oBB.height).attr({fill: '#fff', stroke: '#fff', opacity: 0.9}).toBack());
+				break;
+			}
+			if (oNode.source)
+			{
+				oNode.aElements.push(this.oPaper.circle(xPos, yPos, 1.25*iWidth*this.fZoom / 2).attr({stroke: '#c33', 'stroke-width': 3*this.fZoom }).toBack());
+			}
+			if (oNode.sink)
+			{
+				oNode.aElements.push(this.oPaper.circle(xPos, yPos, 1.25*iWidth*this.fZoom / 2).attr({stroke: '#33c', 'stroke-width': 3*this.fZoom }).toBack());
+			}
+			
+			var me = this;
+			for(k in oNode.aElements)
+			{
+				var sNodeId = oNode.id;
+				oNode.aElements[k].drag(function(dx, dy, x, y, event) { me._move(sNodeId, dx, dy, x, y, event); }, function(x, y, event) { me._drag_start(sNodeId, x, y, event); }, function (event) { me._drag_end(sNodeId, event); });
+			}
+		},
+		_move: function(sNodeId, dx, dy, x, y, event)
+		{
+			var origDx = dx / this.fZoom;
+			var origDy = dy / this.fZoom;
+			
+			var oNode = this._find_node(sNodeId);
+			oNode.x = oNode.xOrig + origDx;
+			oNode.y = oNode.yOrig + origDy;
+			
+			for(k in oNode.aElements)
+			{
+				oNode.aElements[k].transform('t'+(oNode.tx + dx)+', '+(oNode.ty + dy));
+				
+				for(j in this.aEdges)
+				{
+					var oEdge = this.aEdges[j];
+					if ((oEdge.source_node_id == sNodeId) || (oEdge.sink_node_id == sNodeId))
+					{
+						var sPath = this._get_edge_path(oEdge);
+						oEdge.aElements[0].attr({path: sPath});
+					}
+				}
+			}
+		},
+		_drag_start: function(sNodeId, x, y, event)
+		{
+			var oNode = this._find_node(sNodeId);
+			oNode.xOrig = oNode.x;
+			oNode.yOrig = oNode.y;
+			
+		},
+		_drag_end: function(sNodeId, event)
+		{
+			var oNode = this._find_node(sNodeId);
+			oNode.tx += (oNode.x - oNode.xOrig) * this.fZoom;
+			oNode.ty += (oNode.y - oNode.yOrig) * this.fZoom;
+			oNode.xOrig = oNode.x;
+			oNode.yOrig = oNode.y;
+		},
+		_get_edge_path: function(oEdge)
+		{
+			var oStart = this._find_node(oEdge.source_node_id);
+			var oEnd = this._find_node(oEdge.sink_node_id);
+			var iArrowSize = 5;
+			
+			if ((oStart == null) || (oEnd == null)) return '';
+			
+			var xStart = Math.round(oStart.x * this.fZoom + this.xOffset);
+			var yStart = Math.round(oStart.y * this.fZoom + this.yOffset);
+			var xEnd = Math.round(oEnd.x * this.fZoom + this.xOffset);
+			var yEnd = Math.round(oEnd.y  * this.fZoom + this.yOffset);
+
+			var sPath = Raphael.format('M{0},{1}L{2},{3}', xStart, yStart, xEnd, yEnd);
+			var vx = (xEnd - xStart);
+			var vy = (yEnd - yStart);
+			var l = Math.sqrt(vx*vx+vy*vy);
+			vx = vx / l;
+			vy = vy / l;
+			var ux = -vy;
+			var uy = vx;
+			var lPos = Math.max(l/2, l - 40*this.fZoom);
+			var xArrow = xStart + vx * lPos;
+			var yArrow = yStart + vy * lPos;
+			sPath += Raphael.format('M{0},{1}l{2},{3}M{4},{5}l{6},{7}', xArrow, yArrow, this.fZoom * iArrowSize *(-vx + ux),  this.fZoom * iArrowSize *(-vy + uy), xArrow, yArrow, this.fZoom * iArrowSize *(-vx - ux),  this.fZoom * iArrowSize *(-vy - uy));
+			return sPath;
+		},
+		_draw_edge: function(oEdge)
+		{
+			var fStrokeSize = Math.max(1, 2 * this.fZoom);			
+			var sPath = this._get_edge_path(oEdge);
+			var oAttr = $.extend(oEdge.attr);
+			oAttr['stroke-linecap'] = 'round';
+			oAttr['stroke-width'] = fStrokeSize;		
+			oEdge.aElements.push(this.oPaper.path(sPath).attr(oAttr).toBack());
+		},
+		_find_node: function(sId)
+		{
+			for(var k in this.aNodes)
+			{
+				if (this.aNodes[k].id == sId) return this.aNodes[k];
+			}
+			return null;
+		},
+		auto_scale: function()
+		{
+			var fMaxZoom = 1.5;
+			iMargin = 10;
+			xmin = this.options.xmin - iMargin;
+			xmax = this.options.xmax + iMargin;
+			ymin = this.options.ymin - iMargin;
+			ymax = this.options.ymax + iMargin;
+			var xScale = this.element.width() / (xmax - xmin);
+			var yScale = this.element.height() / (ymax - ymin + this.iTextHeight);
+			
+			this.fZoom = Math.min(xScale, yScale, fMaxZoom);
+			switch(this.options.align)
+			{
+				case 'left':
+				this.xOffset = -xmin * this.fZoom;
+				break;
+				
+				case 'right':
+				this.xOffset = (this.element.width() - (xmax - xmin) * this.fZoom);
+				break;
+				
+				case 'center':
+				this.xOffset = (this.element.width() - (xmax - xmin) * this.fZoom) / 2;
+				break;			
+			}
+			switch(this.options['vertical-align'])
+			{
+				case 'top':
+				this.yOffset = -ymin * this.fZoom;
+				break;
+				
+				case 'bottom':
+				this.yOffset = this.element.height() - (ymax + this.iTextHeight) * this.fZoom;
+				break;
+				
+				case 'middle':
+				this.yOffset = (this.element.height() - (ymax - ymin + this.iTextHeight) * this.fZoom) / 2;
+				break;			
+			}
+			
+			
+		},
+		add_node: function(oNode)
+		{
+			oNode.aElements = [];
+			this.aNodes.push(oNode);
+		},
+		add_edge: function(oEdge)
+		{
+			oEdge.aElements = [];
+			this.aEdges.push(oEdge);
+		},
+		_create_toolkit_menu: function()
+		{
+			var sPopupMenuId = 'tk_graph'+this.element.attr('id');
+			var sHtml = '<div class="itop_popup toolkit_menu" style="font-size: 12px;" id="'+sPopupMenuId+'"><ul><li><img src="../images/toolkit_menu.png"><ul>';
+			sHtml += '<li><a href="#" id="'+sPopupMenuId+'_pdf">Export as PDF</a></li>';
+			sHtml += '<li><a href="#" id="'+sPopupMenuId+'_document">Export as document...</a></li>';
+			sHtml += '<li><a href="#" id="'+sPopupMenuId+'_reload">Refresh</a></li>';
+			sHtml += '</ul></li></ul></div>';
+			
+			this.element.before(sHtml);
+			$('#'+sPopupMenuId).popupmenu();
+			
+			var me = this;
+			$('#'+sPopupMenuId+'_pdf').click(function() { me.export_as_pdf(); });
+			$('#'+sPopupMenuId+'_document').click(function() { me.export_as_document(); });
+			$('#'+sPopupMenuId+'_reload').click(function() { me.reload(); });
+			
+		},
+		export_as_pdf: function()
+		{
+			alert('Export as PDF: not yet implemented');
+		},
+		export_as_document: function()
+		{
+			alert('Export as document: not yet implemented');
+		},
+		reload: function()
+		{
+			alert('Reload: not yet implemented');
+		}
+	});	
+});

+ 51 - 39
pages/UI.php

@@ -239,7 +239,7 @@ function DisplayNavigatorListTab($oP, $aResults, $sRelation, $oObj)
 	$oP->add("</div>");
 }
 
-function DisplayNavigatorGraphicsTab($oP, $aResults, $sClass, $id, $sRelation, $oAppContext)
+function DisplayNavigatorGraphicsTab($oP, $aResults, $oRelGraph, $sClass, $id, $sRelation, $oAppContext, $bDirectionDown)
 {
 	$oP->SetCurrentTab(Dict::S('UI:RelationshipGraph'));
 
@@ -273,42 +273,23 @@ EOF
 	$oP->add("</div>\n");
 	$oP->add("<div class=\"HRDrawer\"></div>\n");
 	$oP->add("<div id=\"dh_flash\" class=\"DrawerHandle\">".Dict::S('UI:ElementsDisplayed')."</div>\n");
+	
+	$sDirection = utils::ReadParam('d', 'horizontal');
+	$iGroupingThreshold = utils::ReadParam('g', 5);
 		
-	$width = 1000;
-	$height = 700;
-	$sDrillUrl = utils::GetAbsoluteUrlAppRoot().'pages/UI.php?operation=details&'.$oAppContext->GetForLink();
-	$sParams = "pWidth=$width&pHeight=$height&drillUrl=".urlencode($sDrillUrl)."&displayController=false&xmlUrl=".urlencode("./xml.navigator.php")."&obj_class=$sClass&obj_id=$id&relation=$sRelation";
-		
-	$oP->add("<div style=\"z-index:1;background:white;width:100%;height:{$height}px\"><object style=\"z-index:2\" classid=\"clsid:d27cdb6e-ae6d-11cf-96b8-444553540000\" codebase=\"http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=7,0,0,0\" width=\"100%\" height=\"$height\" id=\"navigator\" align=\"middle\">
-	<param name=\"allowScriptAccess\" value=\"always\" />
-	<param name=\"allowFullScreen\" value=\"false\" />
-	<param name=\"FlashVars\" value=\"$sParams\" />
-	<param name=\"wmode\" value=\"transparent\"> 
-	<param name=\"movie\" value=\"../navigator/navigator.swf\" /><param name=\"quality\" value=\"high\" /><param name=\"bgcolor\" value=\"#ffffff\" />
-	<embed src=\"../navigator/navigator.swf\" wmode=\"transparent\" flashVars=\"$sParams\" quality=\"high\" bgcolor=\"#ffffff\" width=\"100%\" height=\"$height\" name=\"navigator\" align=\"middle\" swliveconnect=\"true\" allowScriptAccess=\"always\" allowFullScreen=\"false\" type=\"application/x-shockwave-flash\" pluginspage=\"http://www.adobe.com/go/getflashplayer\" />
-	</object></div>\n");
+	$oP->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/fraphael.js');
+	$oP->add_linked_script(utils::GetAbsoluteUrlAppRoot().'js/simple_graph.js');
+	$oGraph = DisplayableGraph::FromRelationGraph($oRelGraph, $iGroupingThreshold, $bDirectionDown);
+	$oGraph->InitFromGraphviz();
+	$oGraph->RenderAsRaphael($oP);
+	$oP->p('<a target="_blank" href="'.utils::GetAbsoluteUrlAppRoot().'/pages/ajax.render.php?operation=relation_pdf&relation='.$sRelation.'&direction='.($bDirectionDown ? 'down' : 'up').'&class='.$sClass.'&id='.$id.'">'.Dict::S('UI:GraphAsPDF').'</a>');
+	
 	$oP->add_script(
 <<<EOF
-function getFlashMovieObject(movieName)
-{
-  if (window.document[movieName]) 
-  {
-      return window.document[movieName];
-  }
-  if (navigator.appName.indexOf("Microsoft Internet")==-1)
-  {
-    if (document.embeds && document.embeds[movieName])
-      return document.embeds[movieName]; 
-  }
-  else // if (navigator.appName.indexOf("Microsoft Internet")!=-1)
-  {
-    return document.getElementById(movieName);
-  }
-}	
+			
 	function DoReload()
 	{
 		$('#ReloadMovieBtn').button('disable');
-		var oMovie = getFlashMovieObject('navigator');
 		try
 		{
 			var aExcluded = [];
@@ -318,8 +299,7 @@ function getFlashMovieObject(movieName)
 					aExcluded.push($(this).val());
 				}
 			} );
-			oMovie.Filter(aExcluded.join(','));
-		//oMovie.SetVariable("/:message", "foo");
+			alert('Not yet implemented');
 		}
 		catch(err)
 		{
@@ -352,8 +332,7 @@ EOF
 			ajax_request = $.get(GetAbsoluteUrlAppRoot()+'pages/xml.navigator.php', { 'class': sClass, id: iId, relation: sRelation, format: 'html' },
 					function(data)
 					{
-						$('#impacted_objects').empty();
-						$('#impacted_objects').append(data);
+						alert('Not yet implemented');
 						$('#impacted_objects').unblock();
 					}
 			);
@@ -1530,14 +1509,47 @@ EOF
 		///////////////////////////////////////////////////////////////////////////////////////////
 		
 		case 'swf_navigator': // Graphical display of the relations "impact" / "depends on"
+		require_once(APPROOT.'core/simplegraph.class.inc.php');
+		require_once(APPROOT.'core/relationgraph.class.inc.php');
+		require_once(APPROOT.'core/displayablegraph.class.inc.php');
 		$sClass = utils::ReadParam('class', '', false, 'class');
 		$id = utils::ReadParam('id', 0);
 		$sRelation = utils::ReadParam('relation', 'impact');
+		$sDirection = utils::ReadParam('direction', 'down');
 
-		$aResults = array();
 		$oObj = MetaModel::GetObject($sClass, $id);
 		$iMaxRecursionDepth = MetaModel::GetConfig()->Get('relations_max_depth', 20);
-		$oObj->GetRelatedObjects($sRelation, $iMaxRecursionDepth /* iMaxDepth */, $aResults);
+		$aSourceObjects = array($oObj);
+		if ($sRelation == 'depends on')
+		{
+			$sRelation = 'impacts';
+			$sDirection = 'up';
+		}
+		if ($sDirection == 'up')
+		{
+			$oRelGraph = MetaModel::GetRelatedObjectsUp($sRelation, $aSourceObjects, $iMaxRecursionDepth);
+		}
+		else
+		{
+			$oRelGraph = MetaModel::GetRelatedObjectsDown($sRelation, $aSourceObjects, $iMaxRecursionDepth);
+		}
+		
+
+		$aResults = array();
+		$oIterator = new RelationTypeIterator($oRelGraph, 'Node');
+		foreach($oIterator as $oNode)
+		{
+			$oObj = $oNode->GetProperty('object'); // Some nodes (Redundancy Nodes) do not contain an object
+			if ($oObj)
+			{
+				$sObjClass  = get_class($oObj);
+				if (!array_key_exists($sClass, $aResults))
+				{
+					$aResults[$sObjClass] = array();
+				}
+				$aResults[$sObjClass][] = $oObj;
+			}
+		}
 		
 		$oP->AddTabContainer('Navigator');
 		$oP->SetCurrentTabContainer('Navigator');
@@ -1546,11 +1558,11 @@ EOF
 		if ($sFirstTab == 'list')
 		{
 			DisplayNavigatorListTab($oP, $aResults, $sRelation, $oObj);
-			DisplayNavigatorGraphicsTab($oP, $aResults, $sClass, $id, $sRelation, $oAppContext);
+			DisplayNavigatorGraphicsTab($oP, $aResults, $oRelGraph, $sClass, $id, $sRelation, $oAppContext, ($sDirection == 'down'));
 		}
 		else
 		{
-			DisplayNavigatorGraphicsTab($oP, $aResults, $sClass, $id, $sRelation, $oAppContext);
+			DisplayNavigatorGraphicsTab($oP, $aResults, $oRelGraph, $sClass, $id, $sRelation, $oAppContext, ($sDirection == 'down'));
 			DisplayNavigatorListTab($oP, $aResults, $sRelation, $oObj);
 		}
 

+ 36 - 1
pages/ajax.render.php

@@ -1724,7 +1724,42 @@ EOF
 		// Stop & cleanup an export...
 		$sToken = utils::ReadParam('token', '', false, 'raw_data');
 		ExcelExporter::CleanupFromToken($sToken);
-		break;		
+		break;
+
+		case 'relation_pdf':
+		require_once(APPROOT.'core/simplegraph.class.inc.php');
+		require_once(APPROOT.'core/relationgraph.class.inc.php');
+		require_once(APPROOT.'core/displayablegraph.class.inc.php');
+		$sClass = utils::ReadParam('class', '', false, 'class');
+		$id = utils::ReadParam('id', 0);
+		$sRelation = utils::ReadParam('relation', 'impact');
+		$sDirection = utils::ReadParam('direction', 'down');
+		
+		$iGroupingThreshold = utils::ReadParam('g', 5);
+		$sPageFormat = utils::ReadParam('p', 'A4');
+		$sPageOrientation = utils::ReadParam('o', 'L');
+		$sTitle = utils::ReadParam('title', '', false, 'raw_data');
+		
+		$oObj = MetaModel::GetObject($sClass, $id);
+		$iMaxRecursionDepth = MetaModel::GetConfig()->Get('relations_max_depth', 20);
+		$aSourceObjects = array($oObj);
+		if ($sDirection == 'up')
+		{
+			$oRelGraph = MetaModel::GetRelatedObjectsUp($sRelation, $aSourceObjects, $iMaxRecursionDepth);
+		}
+		else
+		{
+			$oRelGraph = MetaModel::GetRelatedObjectsDown($sRelation, $aSourceObjects, $iMaxRecursionDepth);
+		}
+		
+
+		$oGraph = DisplayableGraph::FromRelationGraph($oRelGraph, $iGroupingThreshold, ($sDirection == 'down'));
+		$oGraph->InitFromGraphviz();
+		$oGraph->RenderAsPDF($oPage, $sTitle, $sPageFormat, $sPageOrientation);
+		
+		$oPage->SetContentType('application/pdf');
+		$oPage->SetContentDisposition('inline', 'iTop.pdf');
+		break;
 		
 
 		default: