Prototyping a Project Management App - Part 6

If the previous post, we covered the logic for creating a new step and positioning it correctly on our screen. Next we will cover, possibly the most complex part of our code, the re-sizing and re-positioning of our steps as new steps are added.

Let's start with the function "resizeAndRepositionStep"

var resizeAndRepositionStep = function (stepToResize) {
 // Update box width and height with new values:
  // box needs to be large enough to encase all of its sub-steps
 // Making this box a different size:
  // will affect the size of:
   // this box's parent step
  // will affect the positioning of: 
   // boxes below
   // boxes to the right of this box
 resizeStep(stepToResize);
 // Reposition the step in case other steps have moved
 repositionStep(stepToResize);                
 // Tell prior Steps then parent Step to resize as well
 // This will also trigger repositioning as needed
 if (stepToResize.priorSteps.length > 0) {
  for (var i = 0; i < stepToResize.priorSteps.length; i++) {
   resizeAndRepositionStep(stepToResize.priorSteps[i]);
  }
 }
 else if (stepToResize.parentStep != null) {
  resizeAndRepositionStep(stepToResize.parentStep);
 }                
}

This function starts by calling the function "resizeStep" to update the dimensions of the current box. It then calls the function "respositionStep" to have the current box also re-position itself. As we'll see from looking at "repositionStep" further down this page, "repositionStep" handles re-positioning the current state as well as subsequent next steps at the same level and any sub-steps within each of those steps. This takes care of things from this level but sometimes we'll even need to re-position steps that live either earlier in our chain of steps or even higher up in our tree of steps. As such, after calling the re-size and re-position functions, our code then recursively runs itself on all prior steps that point to the current step and if the current step has no prior steps, it then recursively runs itself on its parent step instead, right up until it reaches the root of our step tree.

That's quite a lot to take in, but let's look at the functions "resizeStep" and "repositionStep" to see exactly what they are doing, and hopefully it'll all start to make sense.

var resizeStep = function (stepToResize) {
 // We need to get the dimensions of all sub-steps to set the dimensions of this box
 var dimensionsOfSubSteps = getDimensionsOfSubSteps(stepToResize);
 // Add some padding to the top and bottom of the box
 var paddingAmount = 2 * Configuration.subStepPadding;
 var width = dimensionsOfSubSteps.width + paddingAmount;
 var height = dimensionsOfSubSteps.height + paddingAmount;
 // If our box doesn't have any sub-steps, just set its dimensions to the default
 if (width < Configuration.minBoxWidth) {
  width = Configuration.minBoxWidth;
 }
 if (height < Configuration.minBoxHeight) {
  height = Configuration.minBoxHeight;
 }
 // Update Step Values
 stepToResize.width = width;
 stepToResize.height = height;
 // Render the box with new dimensions
 if (stepToResize.d3Rectangle != null) {
  stepToResize.d3Rectangle
   .attr("width", width)
   .attr("height", height);
 }
}

The function "resizeStep" is actually quite simple. It makes use of the function "getDimensionsOfSubSteps" to get the width and height dimensions of all the sub-steps that sit within the current step. Once it has those dimensions, it simply sets the width and height of the current box equal to those dimensions plus a bit of padding. To update these values, it updates the width and height values of our step object as well as the actual d3 rectangle values as well (this is the part that updates the shape on the screen).

The next function "repositionStep" is probably the most complex function in terms of things to consider. It was certainly the function that took the most thought and repeated refining before it performed as desired and made sense. Now that all the tweaking is done, it actually doesn't look that complicated (compared to some ugly first drafts that I won't taint you with!). It's usually a sign that you are on the right track if your final function is fairly clean and readable. Having said that, there are still a number of different pieces to this function, so I'll attempt to review them one at a time. Here's the function in its entirety:


var repositionStep = function (stepToReposition) {
 var x = 0;
 var y = 0;
 // If stepType is a "Beginning Step" or first "Sub-Step"
 if (stepToReposition.priorSteps.length == 0) {
  // Render a box at the top left of the screen
  // but without overlapping any existing boxes
  if (stepToReposition.parentStep != null) {
   // Need to get dimensions of siblings above self
   // find the subStep index of stepToReposition
   var subStepIndexOfStepToReposition = 0;
   for (var i = 0; i < stepToReposition.parentStep.subSteps.length; i++) {
    if (stepToReposition.parentStep.subSteps[i] == stepToReposition) {
     subStepIndexOfStepToReposition = i;
     break;
    }
   }
   var dimensionsOfSiblings = getDimensionsOfSubSteps(stepToReposition.parentStep, 0, subStepIndexOfStepToReposition);
   x = stepToReposition.parentStep.x + Configuration.subStepPadding;
   if (dimensionsOfSiblings.height == 0) {
    y = stepToReposition.parentStep.y + Configuration.subStepPadding;
   }
   else {
    y = stepToReposition.parentStep.y + Configuration.subStepPadding + dimensionsOfSiblings.height + Configuration.minHeightBetweenBoxes;
   }
  }
 }
 // If stepType == "Next Step"
 else {
  // Render a box directly to the right of stepComingFrom
  // without overlapping any existing boxes
  // Render a new box using the following values for x and y
  var stepComingFrom = stepToReposition.priorSteps[0];
  x = stepComingFrom.x + stepComingFrom.width + Configuration.minWidthBetweenBoxes;
  // find nextStep index of stepToReposition
  var nextStepIndexOfStepToReposition = 0;
  for (var i = 0; i < stepComingFrom.nextSteps.length; i++) {
   if (stepComingFrom.nextSteps[i] == stepToReposition) {
    nextStepIndexOfStepToReposition = i;
    break;
   }
  }
  var dimensionsOfExistingNextSteps = getDimensionsOfNextSteps(stepComingFrom, null, nextStepIndexOfStepToReposition);
  y = 0;
  if (dimensionsOfExistingNextSteps.height > 0) {
   y = stepComingFrom.y + dimensionsOfExistingNextSteps.height + Configuration.minHeightBetweenBoxes;
  }
  else {
   y = stepComingFrom.y;
  }
 }
 // Update the step and d3 object positions
 if (stepToReposition.parentStep != null) {
  stepToReposition.x = x;
  stepToReposition.y = y;
  if (stepToReposition.d3Rectangle != null) {
   stepToReposition.d3Rectangle
    .attr("x", stepToReposition.x)
    .attr("y", stepToReposition.y);
   // If this is a next step, reposition any arrows pointed to it
   if (stepToReposition.priorSteps.length > 0) {
    for (var i = 0; i < stepToReposition.priorSteps.length; i++) {
     var priorStep = stepToReposition.priorSteps[i];
     var arrow = null;
     for (var j = 0; j < stepToReposition.parentStep.arrows.length; j++) {
      var arrowLookup = stepToReposition.parentStep.arrows[j];
      if (arrowLookup.stepFrom == priorStep && arrowLookup.stepTo == stepToReposition) {
       arrow = arrowLookup.d3Arrow;
       break;
      }
     }
     if (arrow != null) {
      arrow.attr("x1", priorStep.x + priorStep.width)
       .attr("y1", priorStep.y + (0.5 * priorStep.height))
       .attr("x2", stepToReposition.x)
       .attr("y2", stepToReposition.y + (0.5 * stepToReposition.height));
     }
    }
   }
  }
 }
 // Reposition Next Steps
 for (var i = 0; i < stepToReposition.nextSteps.length; i++) {
  var nextStep = stepToReposition.nextSteps[i];
  repositionStep(nextStep);                  
 }
 // Reposition Sub Steps
 for (var i = 0; i < stepToReposition.subSteps.length; i++) {
  var subStep = stepToReposition.subSteps[i];
  // should only need to call resposition on starting sub-steps
  // as the function will itself call any next-steps at the same level
  if (subStep.priorSteps.length == 0) {
   repositionStep(subStep);
  }                    
 }
}

One way to look at this function is to compare its logic to that of our primary function for creating a new step "createNewBox". Both functions use approximately the same logic to determine where to place a box, the primary difference being that one function draws a new box whereas the other just adjusts the position of an existing one. There are some subtle differences however, so let's  dive in:


// If stepType is a "Beginning Step" or first "Sub-Step"
if (stepToReposition.priorSteps.length == 0) {
 // Render a box at the top left of the screen
 // but without overlapping any existing boxes
 if (stepToReposition.parentStep != null) {
  // Need to get dimensions of siblings above self
  // find the subStep index of stepToReposition
  var subStepIndexOfStepToReposition = 0;
  for (var i = 0; i < stepToReposition.parentStep.subSteps.length; i++) {
   if (stepToReposition.parentStep.subSteps[i] == stepToReposition) {
    subStepIndexOfStepToReposition = i;
    break;
   }
  }
  var dimensionsOfSiblings = getDimensionsOfSubSteps(stepToReposition.parentStep, 0, subStepIndexOfStepToReposition);
  x = stepToReposition.parentStep.x + Configuration.subStepPadding;
  if (dimensionsOfSiblings.height == 0) {
   y = stepToReposition.parentStep.y + Configuration.subStepPadding;
  }
  else {
   y = stepToReposition.parentStep.y + Configuration.subStepPadding + dimensionsOfSiblings.height + Configuration.minHeightBetweenBoxes;
  }
 }
}

When moving a sub-step, the primary difference between this function and "createNewBox" is that when this function calls "getDimensionsOfSubSteps" it passes it two additional parameters, "0" and "subStepIndexOfStepToReposition". The exact logic of "getDimensionsOfSubSteps" will be covered later, but what these parameters do, is tell the function to only return the dimensions of the first set of sub-steps from position 0 (javascript arrays start at 0, not 1, so this represents the first sub-step in the array) through to the sub-step just above the step that we are re-positioning. This makes sense, if you take a second to think about what our re-positioning needs to know. Our box wants to be positioned just below any sub-steps that sit above it, and that height is exactly what this filtered version of "getDimensionsOfSubSteps" returns.

Looking at the logic for re-positioning a "next step", you should see a similar story:


// If stepType == "Next Step"
else {
 // Render a box directly to the right of stepComingFrom
 // without overlapping any existing boxes
 // Render a new box using the following values for x and y
 var stepComingFrom = stepToReposition.priorSteps[0];
 x = stepComingFrom.x + stepComingFrom.width + Configuration.minWidthBetweenBoxes;
 // find nextStep index of stepToReposition
 var nextStepIndexOfStepToReposition = 0;
 for (var i = 0; i < stepComingFrom.nextSteps.length; i++) {
  if (stepComingFrom.nextSteps[i] == stepToReposition) {
   nextStepIndexOfStepToReposition = i;
   break;
  }
 }
 var dimensionsOfExistingNextSteps = getDimensionsOfNextSteps(stepComingFrom, 0, nextStepIndexOfStepToReposition);
 y = 0;
 if (dimensionsOfExistingNextSteps.height > 0) {
  y = stepComingFrom.y + dimensionsOfExistingNextSteps.height + Configuration.minHeightBetweenBoxes;
 }
 else {
  y = stepComingFrom.y;
 }
}

When we call "getDimensionsOfNextSteps" we pass it the parameters "0" and "nextStepIndexOfStepToReposition", which instructs the function to return only the dimensions for the subset of steps from 0 to just before the step we are re-positioning.

Once we are done with calculating the new coordinates for our box, it's just a case of updating the actual d3 rectangle and also any arrows pointing towards it:


// Update the step and d3 object positions
if (stepToReposition.parentStep != null) {
 stepToReposition.x = x;
 stepToReposition.y = y;
 if (stepToReposition.d3Rectangle != null) {
  stepToReposition.d3Rectangle
   .attr("x", stepToReposition.x)
   .attr("y", stepToReposition.y);
  // If this is a next step, reposition any arrows pointed to it
  if (stepToReposition.priorSteps.length > 0) {
   for (var i = 0; i < stepToReposition.priorSteps.length; i++) {
    var priorStep = stepToReposition.priorSteps[i];
    var arrow = null;
    for (var j = 0; j < stepToReposition.parentStep.arrows.length; j++) {
     var arrowLookup = stepToReposition.parentStep.arrows[j];
     if (arrowLookup.stepFrom == priorStep && arrowLookup.stepTo == stepToReposition) {
      arrow = arrowLookup.d3Arrow;
      break;
     }
    }
    if (arrow != null) {
     arrow.attr("x1", priorStep.x + priorStep.width)
      .attr("y1", priorStep.y + (0.5 * priorStep.height))
      .attr("x2", stepToReposition.x)
      .attr("y2", stepToReposition.y + (0.5 * stepToReposition.height));
    }
   }
  }
 }
}

After we have re-positioned the box itself, we still need to inform all subsequent next steps and all sub-steps contained within this step to update their own positions as any changes in this step will need to be reflected:


// Reposition Next Steps
for (var i = 0; i < stepToReposition.nextSteps.length; i++) {
 var nextStep = stepToReposition.nextSteps[i];
 repositionStep(nextStep);                  
}
// Reposition Sub Steps
for (var i = 0; i < stepToReposition.subSteps.length; i++) {
 var subStep = stepToReposition.subSteps[i];
 // should only need to call resposition on starting sub-steps
 // as the function will itself call any next-steps at the same level
 if (subStep.priorSteps.length == 0) {
  repositionStep(subStep);
 }                    
}

In this last part of the function, the function calls itself but passes either a next step or a sub-step so the same logic of re-positioning is carried out for all other steps along or down the chain of steps.

Let's wrap up by looking at our last set of functions. Here is "getDimensionsOfSubSteps":


var getDimensionsOfSubSteps = function (currentStep, startAtSubStepIndex, stopAtSubStepIndex) {
 // define variables to track width and height
 var width = 0;
 var height = 0;                
 var i = 0;
 var iEnd = currentStep.subSteps.length;
 if (startAtSubStepIndex != null) {
  i = startAtSubStepIndex;
 }
 if (stopAtSubStepIndex != null) {
  iEnd = stopAtSubStepIndex;
 }
 // for each sub step
 for (i; i < iEnd; i++) {
  var subStep = currentStep.subSteps[i];
  // just take subSteps that are beginning steps
  if (subStep.priorSteps.length == 0) {
   // get dimensions of the sub-step and any subsequent next steps
   var dimensionsOfSubStepAndItsNextSteps = getDimensionsOfCurrentStepAndAllNextSteps(subStep);
   if (dimensionsOfSubStepAndItsNextSteps.width > width) {
    width = dimensionsOfSubStepAndItsNextSteps.width;
   }
   if (i == 0) {
    height = height + dimensionsOfSubStepAndItsNextSteps.height;
   }
   else {
    height = height + dimensionsOfSubStepAndItsNextSteps.height + Configuration.minHeightBetweenBoxes;
   }
  }                    
 }                    
 // return width and height
 return {
  width: width,
  height: height
 }
}

This function loops through all the sub-steps within a step and calls another function "getDimensionsOfCurrentStepAndAllNextSteps" on each sub-step. It then sets width equal to the widest of the returning dimensions and sets height equal to the sum of all the returning heights plus some fixed amount for spacing. This function can be filtered using the variables "startAtSubStepIndex" and "stopAtSubStepIndex" to limit the range of sub-steps that are measured.

The function "getDimensionsOfNextSteps" is fairly similar in nature:


var getDimensionsOfNextSteps = function (currentStep, startAtNextStepIndex, stopAtNextStepIndex) {
 // define variables to track width and height
 var width = 0;
 var height = 0;                
 var i = 0;
 var iEnd = currentStep.nextSteps.length;
 if (startAtNextStepIndex != null) {
  i = startAtNextStepIndex;
 }
 if (stopAtNextStepIndex != null) {
  iEnd = stopAtNextStepIndex;
 }
 // for each next step
 for (i; i < iEnd; i++) {
  // call getDimensionsOfNextSteps and pass it the next step
  var nextStep = currentStep.nextSteps[i];
  var dimensions = getDimensionsOfNextSteps(nextStep);
  // update width and height values
  // if widthOfNextSteps > width
  if (nextStep.width + dimensions.width > width) {
   // width = widthOfNextStep
   if (dimensions.width > 0) {
    width = nextStep.width + Configuration.minWidthBetweenBoxes + dimensions.width;
   }
   else {
    width = nextStep.width;
   }                        
  }
  // height = height + heightOfNextSteps
  if (nextStep.height > dimensions.height) {
   if (i == 0) {
    height = height + nextStep.height;
   }
   else {
    height = height + nextStep.height + Configuration.minHeightBetweenBoxes;
   }                        
  }
  else {
   if (i == 0) {
    height = height + dimensions.height;
   }
   else {
    height = height + Configuration.minHeightBetweenBoxes + dimensions.height;
   }                        
  }                                  
 }                    
 // return width and height
 return {
  width: width,
  height: height
 }
}

This function loops through all the immediate next steps after a step and recursively calls itself on each subsequent next step. It then sets width equal to the widest of the returning dimensions and sets height equal to the sum of all the returning heights plus some fixed amount for spacing. This function can be filtered using the variables "startAtNextStepIndex" and "stopAtNextStepIndex" to limit the range of next steps that are measured.

The last function we'll look at for now is "getDimensionsOfCurrentStepAndAllNextSteps":


var getDimensionsOfCurrentStepAndAllNextSteps = function (currentStep) {
 // define variables to track width and height and set them
 // to the current values of the currentStep width and height
 var width = currentStep.width;
 var height = currentStep.height;
 // call getDimensionsOfNextSteps
 var dimensions = getDimensionsOfNextSteps(currentStep);
 // update the width and height values
 if (dimensions.width > 0) {
  width = width + Configuration.minWidthBetweenBoxes + dimensions.width;
 }
 // if heightOfNextSteps > height
 if (dimensions.height > height) {
  // height = heightOfNextSteps
  height = dimensions.height;
 }                    
 // return width and height
 return {
  width: width,
  height: height
 } 
}

This function really just calls "getDimensionsOfNextSteps" but also accounts for the dimensions of the current step and includes those in the final calculation.


Comments

Popular Posts