#!/usr/bin/env ruby -w # encoding: UTF-8 # # = GanttRouter.rb -- The TaskJuggler III Project Management Software # # Copyright (c) 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014 # by Chris Schlaeger # # This program is free software; you can redistribute it and/or modify # it under the terms of version 2 of the GNU General Public License as # published by the Free Software Foundation. # require 'taskjuggler/reports/CollisionDetector' class TaskJuggler # The GanttRouter is used by the GanttChart to route the dependency lines from # the start to the end point. The chart is a rectangular area with a certain # width and height. The graphical elements of the Gantt chart can be # registered as don't-cross-zones. These zones block the either horizontal or # vertical lines (or both) from crossing the zone. Zones can be registered by # calling addZone(). The route() method returns routed path from # start to end point. class GanttRouter # Minimum distance between the starting point and the first turning point. MinStartGap = 5 # Minimum distance between the last turning point and the tip of the # arrow. MinEndGap = 10 # Create a GanttRouter object. _width_ and _height_ describe the size of the # rectangular area this router is operating on. def initialize(width, height) @width = width.to_i @height = height.to_i @detector = CollisionDetector.new(@width, @height) end def addZone(x, y, w, h, horiz, vert) @detector.addBlockedZone(x, y, w, h, horiz, vert) end def routeLines(fromToPoints) # We first convert the fromToPoints list into a more readable list of # Hash objects. routes = [] fromToPoints.each do |touple| routes << { :startX => touple[0], :startY => touple[1], :endX => touple[2], :endY => touple[3], :id => touple[4] } end # To make sure that we minimize the crossings of arrows that # originate from the same position, we sort the arrows by the # smallest angle between the vertical line through the task end # and the line between the start and end of the arrow. routes.each do |r| adjLeg = (r[:endX] - MinEndGap) - (r[:startX] + MinStartGap) oppLeg = (r[:startY] - r[:endY]).abs r[:distance] = Math.sqrt(adjLeg ** 2 + oppLeg ** 2) # We can now calculate the sinus values of the angle between the # vertical and a line through the coordinates. sinus = oppLeg.abs / r[:distance] r[:angle] = (adjLeg < 0 ? Math::PI / 2 + Math.asin(Math::PI/2 - sinus) : Math.asin(sinus)) / (Math::PI / (2 * 90)) end # We sort the arrows from small to a large angle. In case the angle is # identical, we use the length of the line as second criteria. routes.sort! { |r1, r2| (r1[:angle] / 5).to_i == (r2[:angle] / 5).to_i ? -(r1[:distance] <=> r2[:distance]) : -(r1[:angle] <=> r2[:angle]) } # Now that the routes are in proper order, we can actually lay the # routes. routePoints = [] routes.each do |r| routePoints << route(r[:startX], r[:startY], r[:endX], r[:endY]) end routePoints end # Find a non-blocked route from the _startPoint_ [ x, y ] to the # _endPoint_ [ x, y ]. The route always starts from the start point towards # the right side of the chart and reaches the end point from the left side # of the chart. All lines are always strictly horizontal or vertical. There # are no diagonal lines. The result is an Array of [ x, y ] points that # include the _startPoint_ as first and _endPoint_ as last element. def route(startX, startY, endX, endY) points = [ [ startX, startY ] ] startGap = MinStartGap endGap = MinEndGap if endX - startX > startGap + endGap + 2 # If the horizontal distance between start and end point is large enough # we can try a direct route. # # xSeg # |startGap| # startX/endX X--------1 # | # | # 2------X endX/endY # |endGap| # xSeg = placeLine([ startY + (startY < endY ? 1 : -1), endY ], false, startX + startGap, 1) if xSeg && xSeg < endX - endGap # The simple version works. Add the lines. addLineTo(points, xSeg, startY) # Point 1 addLineTo(points, xSeg, endY) # Point 2 addLineTo(points, endX, endY) return points end end # If the simple approach above fails, the try a more complex routing # strategy. # # x1 # |startGap| # startX/startY X--------1 yLS # | # 3---------------2 ySeg # | # 4------X endX/endY # |endGap| # x2 # Place horizontal segue. We don't know the width yet, so we have to # assume full width. That's acceptable for horizontal lines. deltaY = startY < endY ? 1 : -1 ySeg = placeLine([ 0, @width - 1 ], true, startY + 2 * deltaY, deltaY) raise "Routing failed" unless ySeg # Place 1st vertical x1 = placeLine([ startY + deltaY, ySeg ], false, startX + startGap, 1) raise "Routing failed" unless x1 # Place 2nd vertical x2 = placeLine([ ySeg + deltaY, endY ], false, endX - endGap, -1) raise "Routing failed" unless x2 # Now add the points 1 - 4 to the list and mark the zones around them. For # vertical lines, we only mark vertical zones and vice versa. addLineTo(points, x1, startY) # Point 1 if x1 != x2 addLineTo(points, x1, ySeg) # Point 2 addLineTo(points, x2, ySeg) # Point 3 end addLineTo(points, x2, endY) # Point 4 addLineTo(points, endX, endY) points end # This function is only intended for debugging purposes. It marks either the # vertical or horizontal zones in the chart. def to_html @detector.to_html end private # This function is at the heart of the routing algorithm. It tries to find a # place for the line described by _segment_ without overlapping with the # defined zones. _horizontal_ determines whether the line is running # horizontally or vertically. _start_ is the first coordinate that is looked # at. In case of collisions, _start_ is moved by _delta_ and the check is # repeated. The function returns the first collision free coordinate or the # outside edge of the routing area. def placeLine(segment, horizontal, start, delta) raise "delta may not be 0" if delta == 0 # Start must be an integer and lie within the routing area. pos = start.to_i pos = 0 if pos < 0 max = (horizontal ? @height: @width) - 1 pos = max if pos > max # Make sure that the segment coordinates are in ascending order. segment.sort! # TODO: Remove this check once the code becomes stable. #checkLines(lines) while @detector.collision?(pos, segment, horizontal) pos += delta # Check if we have exceded the chart area towards top/left. if delta < 0 if pos < 0 break end else # And towards right/bottom. break if pos >= (horizontal ? @height : @width) end end pos end # This function adds another waypoint to an existing line. In addition it # adds a zone that is 2 pixel wide on each side of the line and runs in the # direction of the line. This avoids too closely aligned parallel lines in # the chart. def addLineTo(points, x2, y2) raise "Point list may not be empty" if points.empty? x1, y1 = points[-1] points << [ x2, y2 ] if x1 == x2 # vertical line return if x1 < 0 || x1 >= @width x, y, w, h = justify(x1 - 2, y1, 5, y2 - y1 + 1) addZone(x, y, w, h, false, true) else # horizontal line return if y1 < 0 || x1 >= @height x, y, w, h = justify(x1, y1 - 2, x2 - x1 + 1, 5) addZone(x, y, w, h, true, false) end end # This function makes sure that the rectangle described by _x_, _y_, _w_ # and _h_ is properly justfified. If the width or height are negative, _x_ # and _y_ are adjusted to describe the same rectangle with all positive # coordinates. def justify(x, y, w, h) if w < 0 w = -w x = x - w + 1 end if h < 0 h = -h y = y - h + 1 end # Return the potentially adjusted rectangle coordinates. return x, y, w, h end end end