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"
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.
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:
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:
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:
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:
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:
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":
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:
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.
This function really just calls "getDimensionsOfNextSteps" but also accounts for the dimensions of the current step and includes those in the final calculation.
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
Post a Comment