Creating User Interactive Applications 3

Creating User Interactive Applications Part 3

Using Graphics to enhance the User Interface

Although our app does perform the animated sort in a way that the user can control, the user experience could be improved. The app already controls which buttons are enabled and disabled so the user will not click on the wrong button. The status_label also gives a description of what is happening with each step. But, if we can add some graphics and modify the look of the nodes, the user experience can be enhanced. That is the purpose of this lesson.

Creating a pointer.mjs module

One way to draw the user’s attention to some part of the web application is to use some kind of a pointer (such as an arrow) to point to what you want the user to look at. Let’s work on a module called pointer.mjs that will make it easier for our applications to include such pointers.

Just as we did when creating the select_sort.mjs module, we can create the files testpointer.html, testpointer.mjs and pointer.mjs to work on the pointer.mjs module. Create these files inside the same folder that we have index.html, index.mjs, node.mjs and select_sort.mjs stored in.

testpointer.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <script type="module" src="testpointer.mjs"></script>
    <title>Sorting demo 1</title>
  </head>
  <body>
    <h1>Pointer test</h1>
    <svg
      id="svg_area"
      width="1000"
      height="120"
      xmlns="http://www.w3.org/2000/svg"
    ></svg>
  </body>
</html>

This is just a simple HTML file that contains an area for drawing SVG objects.

testpointer.mjs
import { Pointer } from "./pointer.mjs";

if (document.readyState === "loading") {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

function init() {
  const svg_area = document.getElementById("svg_area");
  const pt1 = new Pointer(50, 70, 30, 30, 5, 'red', 'red-arrow');
  pt1.draw(svg_area);
}

Once the Pointer class is created inside the pointer.mjs module, this will draw a Pointer pointing from (50, 70) to (30, 30) that will have an arrowsize of 5 and a red color. The last argument, 'red-arrow', is needed to identify the Pointer object and will allow drawing more than one pointer with different colors for each.

pointer.mjs
export class Pointer {
  constructor(x1, y1, x2, y2, arrowsize, color, markerId) {
    this.svgNS = "http://www.w3.org/2000/svg";

    this.pointerDefs = document.createElementNS(this.svgNS, 'defs');
    this.arrow = document.createElementNS(this.svgNS, 'marker');
    this.arrow.setAttribute("id", markerId);
    this.arrow.setAttribute("markerWidth", arrowsize);
    this.arrow.setAttribute("markerHeight", arrowsize);
    this.arrow.setAttribute("refX", arrowsize/2);
    this.arrow.setAttribute("refY", arrowsize/2);
    this.arrow.setAttribute("orient", "auto");

    const path = document.createElementNS(this.svgNS, 'path');
    const d_val = `M 0 0 L ${arrowsize} ${arrowsize/2} L 0 ${arrowsize} z`;
    path.setAttribute("d", d_val);
    path.setAttribute("fill", color);

    this.arrow.appendChild(path);
    this.pointerDefs.appendChild(this.arrow);

    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
    this.line.setAttribute("marker-end", `url(#${markerId})`);
  }

  draw(svg_area) {
    svg_area.appendChild(this.pointerDefs);
    svg_area.appendChild(this.line);
  }
}

class Line {
  constructor(x1, y1, x2, y2, color) {

  }
}

export class TwoHeadPointer {
  constructor(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize, color, markerId) {

  }
}

The Pointer class looks fairly complex. Background for how SVG <marker> and SVG <path> are used can be found at the following links, respectively:

Lines 2-30 define the constructor for the Pointer class. This class is used to create an arrow that points from point (x1, y1) to point (x2, y2). At the tip of this line a SVG <marker> element placed inside a SVG <defs> element forms the arrow head. There is no predefined arrow object for SVG, so a SVG <path> in a triangular shape is filled to simulate an arrow head.

Line 3 stores the xmlns (XML namespace) for SVG elements in this.svgNS. Line 5 creates a SVG <defs> element. This is the element that will hold the SVG <marker> element that forms the arrow head. Line 6 creates a SVG <marker> element that will help to create the arrow head. Lines 7-12 sets a number of attributes for that <marker> element. Line 7 gives the <marker> and id. This is important as not giving the pointer an id means that if you tried to use several Pointer objects and gave them different colors, all the pointers would show up with the last color specified. So, an id allows you to have several Pointers all with a different color if you chose to do that. Lines 8 and 9 specify the rectangular shape the <marker> will be enclosed within. Lines 10 and 11 provide offsets in the x and y direction for the <marker> These offsets will make more sense when I show a diagram of how the <marker> dimensions are set up. Line 12 sets the orient attribute to auto. This means that the <marker> will automatically be rotated to line up with the rest of the arrow.

Lines 14-17 set up the SVG <path> element that will form the arrow head that is placed inside of the SVG <marker> element. Line 14 creates a SVG <path> element. Lines 15 and 16 make it so that the d attribute of this <path> is set to a string that defines the path. The M in the string means move to. The numbers following that are the x and y coordinates. The L in the string means line to. The numbers following that are the x and y coordinates the line ends at. The z means to close the path. Suppose arrowsize was 10. The string on line 15 would be:

"M 0 0 L 10 5 L 0 10 z"

The following diagram shows what this path would look like if refX and refY were both 0. Remember that the x-axis increases to the right, and the y-axis increases going down.

marker no offset

If refY = 0, then the arrow head would appear below the line. If refX = 0, the arrow head would appear to the right of the line.

This is why refX and refY are set to arrowsize/2. This will make the arrow head centered on the x-axis and y-axis, as shown in this diagram:

marker with offset

Depending on the values of (x1, y1) and (x2, y2), the x-axis may not be horizontal. This is why we set the orient attribute to auto. This makes the arrow head rotate to align with the direction of the line going from (x1, y1) to (x2, y2).

Line 19 places the <path> inside the <marker>. Line 20 places the <marker> inside the <defs> element.

Lines 22-29 create a SVG <line> that goes from (x1, y1) to (x2, y2). Lines 23-26 sets the x1, y1, x2 and y2 attributes for the line. Line 27 sets the color of the line and line 28 sets the thickness of the line. Line 29 sets the marker-end attribute to the element which has an id of marker-id. That means that the marker-end for the <line> will be the <marker> element with an id="marker-id" attribute.

Lines 32-35, will draw the Pointer by placing the <defs> element inside the svg_area, so that the <line> placed on line 34 can refer to that <defs> element to put the arrowhead at the tip of the line.

Lines 38-42 show a start at the Line class, which we will define later on. Lines 44-47 show a start at the TwoHeadPointer class, which we will define later on as well.

Testing the pointer.mjs module

To test our module in CodeSandbox, make sure that you modify package.json so that main is set to "src/testpointer.html".

To test our module using Vite, just change the address in the browser from localhost:5173 to localhost:5173/testpointer.html.

The following screen shot shows the result of displaying testpointer.html in the browser (or Preview of CodeSandbox):

pointer test1

Creating a Two Headed Pointer

Sometimes, it make sense to use a two headed pointer. That is, a line or path that has arrows on both ends. Here is a diagram that shows how this might work:

twohead pointer

This could be constructed from a Pointer going from (x1, y1) to (x2, y2), a Line going from (x1, y1) to (x3, y3), and finally a Pointer going from (x3, y3) to (x4, y4).

So, let’s modify the pointer.mjs module so that it has a Line class and a TwoHeadPointer class that can accomplish this.

pointer.mjs
export class Pointer {
  constructor(x1, y1, x2, y2, arrowsize, color, markerId) {
    this.svgNS = "http://www.w3.org/2000/svg";

    this.pointerDefs = document.createElementNS(this.svgNS, 'defs');
    this.arrow = document.createElementNS(this.svgNS, 'marker');
    this.arrow.setAttribute("id", markerId);
    this.arrow.setAttribute("markerWidth", arrowsize);
    this.arrow.setAttribute("markerHeight", arrowsize);
    this.arrow.setAttribute("refX", arrowsize/2);
    this.arrow.setAttribute("refY", arrowsize/2);
    this.arrow.setAttribute("orient", "auto");

    const path = document.createElementNS(this.svgNS, 'path');
    const d_val = `M 0 0 L ${arrowsize} ${arrowsize/2} L 0 ${arrowsize} z`;
    path.setAttribute("d", d_val);
    path.setAttribute("fill", color);

    this.arrow.appendChild(path);
    this.pointerDefs.appendChild(this.arrow);

    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
    this.line.setAttribute("marker-end", `url(#${markerId})`);
  }

  draw(svg_area) {
    svg_area.appendChild(this.pointerDefs);
    svg_area.appendChild(this.line);
  }
}

class Line {
  constructor(x1, y1, x2, y2, color) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
  }
}

export class TwoHeadPointer {
  constructor(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize, startcol, middlecol,
    endcol, startId, endId) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.start = new Pointer(x1, y1, x2, y2, arrowsize, startcol, startId);
    this.middle = new Line(x1, y1, x3, y3, middlecol);
    this.end = new Pointer(x3, y3, x4, y4, arrowsize, endcol, endId);
  }

  draw(svg_area) {
    svg_area.appendChild(this.start.pointerDefs);
    svg_area.appendChild(this.end.pointerDefs);
    svg_area.appendChild(this.start.line);
    svg_area.appendChild(this.middle.line);
    svg_area.appendChild(this.end.line);
  }
}

The new lines are 40-47, 52-58 and 60-66. Lines 40-47 help define the Line class. Note that this is not exported, because it is only used within the pointer.mjs module. Line 40 stores "http://www.w3.org/2000/svg" as this.svgNS. Line 41 creates a SVG <line> element. Lines 42-45 set the x1, y1, x2, and y2 coordinates, respectively. Lines 46 and 47 set the color and thickness of the line, respectively.

Lines 52-58 define the constructor for the TwoHeadPointer class. The parameter list was modified to allow for the possibility that the start, middle and end of the pointer could be all different colors. Line 54 sets this.svgNS to the standard namespace for SVG elements. Line 55 uses a Pointer object for the start of the TwoHeadPointer. Line 56 uses a Line object for the middle of the TwoHeadPointer. Line 57 uses another Pointer object for the end of the TwoHeadPointer. The constructor is quite simple because we have already defined the Pointer and Line classes.

Lines 60-66 define the draw() method for the TwoHeadPointer class. Lines 61 and 62 place the pointerDefs (<defs> element) for the start and end of the TwoHeadPointer into the svg area, respectively. Lines 63 - 65, place the start line, middle line and end line into the svg area, respectively.

So, the definition of the TwoHeadPointer class is relatively simple, because the more detailed parts are already taken care of by using the Pointer class. When creating a module, it is a good situation when you can define parts of the module using other parts of that module. This makes for simpler code. Also, it makes it far more likely for the code to work correctly because you probably have already tested the parts that you are reusing. That is, since we already tested out the Pointer class, it makes good sense to define the TwoHeadPointer class using Pointer objects. Those Pointer objects have already been tested.

Testing the TwoHeadPointer

Let’s modify the testpointer.mjs file so that we can test the TwoHeadPointer class. Here is the new version of testpointer.mjs:

testpointer.mjs
import { Pointer, TwoHeadPointer } from "./pointer.mjs";

if (document.readyState === "loading") {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

function init() {
  const svg_area = document.getElementById("svg_area");
  const pt1 = new Pointer(50, 70, 30, 30, 5, 'red', 'red-arrow');
  pt1.draw(svg_area);
  const twoHdPtr = new TwoHeadPointer(60, 60, 60, 30, 90, 60, 90, 90, 5,
    "blue", "black", "red", "startId", "endId");
  twoHdPtr.draw(svg_area);
}

The new lines are 13-15. Lines 13 and 14 construct a TwoHeadPointer object with a starting Pointer going from (60, 60) to (60, 30), a middle line going from (60, 60) to (90,60) and an ending Pointer going from (90, 60) to (90, 90). The start wil lbe colored "blue", the middle will be colored "black" and the end will be colored "red". The arrowsize for the pointers will be 5 pixels. Line 15 draws the TwoHeadPointer object to the svg area.

Here is a screen shot showing the result of running the testpointer.html file:

twohead pointer test

Using the TwoHeadPointer in the main application

If you are using CodeSandbox, remember to modify your package.json file so that the main property is set back to "./src/index.html":

package.json
{
  "name": "javascript",
  "version": "1.0.0",
  "description": "The JavaScript template",
  "main": "./src/index.html",
  "scripts": {
    "start": "parcel ./src/index.html",
    "build": "parcel build ./src/index.html"
  },
  "devDependencies": {
    "parcel": "^2.0.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^7.2.0"
  },
  "keywords": ["css", "javascript"]
}

If you are using Vite, then just set the URL in your browser back to localhost:5173.

Let’s modify index.mjs so that we use a TwoHeadPointer to point from the label for min to the node that will be compared to min. Here is the next version of index.mjs that does that.

index.mjs
import { Node } from "./node.mjs";
import { SelectSorter } from "./select_sort.mjs";
import { TwoHeadPointer } from "./pointer.mjs";

if (document.readyState === "loading") {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

function removeChildren(elem) {
  while (elem.childNodes.length > 0) {
    elem.removeChild(elem.childNodes[0]);
  }
}

function createHandlers() {
  const status_label = document.getElementById("status_label");
  const min_label = document.getElementById("min_label");
  const minPos_label = document.getElementById("minPos_label");
  const comparisons_label = document.getElementById("comparisons_label");
  const swaps_label = document.getElementById("swaps_label");
  let comparisons_count = 0;
  let swaps_count = 0;
  const svg_area = document.getElementById("svg_area");
  let ss; // select sorter object
  let sort_steps = [];
  let node_array = [];
  let step_count = 0;
  let thPtr; // reference to the TwoHeadPointer object
  const width = 30; // width of a Node
  const height = 25; // height of a Node

  function getNodeById(id) {
    for (let i = 0; i < node_array.length; i++) {
      const nd = node_array[i];
      if (Number(nd.g.id) === id) {
        return nd;
      }
    }
  }

  function resetIds() {
    for (let i = 0; i < node_array.length; i++) {
      node_array[i].g.setAttribute("id", i);
    }
  }

  return {

    handleOk(compare_button, swap_button) {
      const numbox = document.getElementById("numbox");
      removeChildren(svg_area);
      //let nums = numbox.value.trim().split(",");
      ss = new SelectSorter(numbox.value);
      step_count = 0;
      sort_steps = ss.getSortSteps();
      const original_array = ss.getOriginalArray();
      node_array = [];
      let xStart = 30;
      let yStart = 60;
      for (let i = 0; i < original_array.length; i++) {
        const num = original_array[i];
        const nd = new Node(xStart + i*(width+20), yStart, width, height, num, i);
        node_array.push(nd); // this is from the original unsorted array
        nd.draw(svg_area);
      }
      //status_label.textContent = "Getting ready to find the min value";
      comparisons_label.textContent = comparisons_count;
      swaps_label.textContent = swaps_count;
      console.log(node_array);
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      min_label.textContent = step.min;
      minPos_label.textContent = step.minPos;
      step_count++;  // go to next step
      const next_step = sort_steps[step_count];
      let msg = `Comparing ${next_step.originalMin} with ${next_step.compareTo}`;
      status_label.textContent = msg;
      console.log('Status:', status_label.textContent);
      compare_button.disabled = false;
      swap_button.disabled = true;
      let x1 = 38;
      let y1 = 30;
      let x2 = 38;
      let y2 = 5;
      let x3 = 96;
      let y3 = 30;
      let x4 = 96;
      let y4 = 50;
      let arrowsize = 5;
      thPtr = new TwoHeadPointer(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize,
        "blue", "blue", "blue", "blue-ptr", "blue-ptr");
      thPtr.draw(svg_area);
    },

    handleCompare(compare_button, swap_button) {
      if (!ss) { return; }
      comparisons_count++;
      comparisons_label.textContent = comparisons_count;
      const step = sort_steps[step_count];
      const next_step = sort_steps[step_count + 1];
      //let msg = `comparing ${step.originalMin} with ${step.compareTo}`
      if (next_step.step === "swap") {
        console.log(JSON.stringify(step));
        compare_button.disabled = true;
        swap_button.disabled = false;
      } else {
        const x = Number(thPtr.end.line.getAttribute("x2"));
        console.log('x', x);
        thPtr.slideEnd(x + width + 20);
        console.log(JSON.stringify(step));
      }
      let msg = "";
      if (step.minChanged === true) {
        msg += "min and minPos are changed. ";
        min_label.textContent = step.min;
        minPos_label.textContent = step.minPos;
      } else {
        msg += "min is unchanged. ";
      }
      msg += `Comparing ${next_step.originalMin} with ${next_step.compareTo}`;
      if (next_step.step === "swap") {
        msg = `Swapping pos: ${next_step.pos1} with pos: ${next_step.pos2}`;
      }
      status_label.textContent = msg;
      console.log('Status:', status_label.textContent);
      step_count++;
    },

    async handleSwap(compare_button, swap_button) {
      if (!ss) {return};
      swaps_count++;
      swaps_label.textContent = swaps_count;
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      const nd1 = getNodeById(step.pos1);
      const nd2 = getNodeById(step.pos2);
      if (step.pos1 !== step.pos2) {
        await nd1.swap(nd2, 700);
        const temp = node_array[step.pos1];
        node_array[step.pos1] = node_array[step.pos2];
        node_array[step.pos2] = temp;
        resetIds();
      } else {
        console.log("node already in correct place");
      }
      if (step_count < sort_steps.length - 1) {
        step_count++;  // advance to "set" step
        const set_step = sort_steps[step_count];
        console.log(JSON.stringify(set_step));
        const next_step = sort_steps[step_count + 1];
        status_label.textContent = `comparing ${set_step.min} with ${next_step.compareTo}.`;
        console.log('Status:',status_label.textContent);
        min_label.textContent = set_step.min;
        minPos_label.textContent = set_step.minPos;
        step_count++;  // advance to "compare" step
        compare_button.disabled = false;
        swap_button.disabled = true;
      } else {
        status_label.textContent = "All done";
        compare_button.disabled = true;
        swap_button.disabled = true;
      }
    }

  }
}

function init() {
  console.log('init called');
  const handlers = createHandlers();
  const ok_button = document.getElementById("ok_button");
  const compare_button = document.getElementById("compare_button");
  const swap_button = document.getElementById("swap_button");
  ok_button.addEventListener('click', () => {
    handlers.handleOk(compare_button, swap_button);
  });
  compare_button.addEventListener('click', () => {
    handlers.handleCompare(compare_button, swap_button);
  });
  swap_button.addEventListener('click', () => {
    handlers.handleSwap(compare_button, swap_button);
  });
}

The lines are 3, 30-32, 64, 83-91, 92-94 and 109-111. Line 3 imports the TwoHeadPointer class from the pointer.mjs module. Line 30 add the private variable thPtr. This will be the reference to the TwoHeadPointer object that we will construct inside the handleOk() handler. Lines 31 and 32 define the width and height of the rectangle used for a Node object, respectively.

Line 64 has been modified to use the width and height values defined on lines 31 and 32. Lines 83-91 define some of the parameters needed for construction the TwoHeadPointer object. Although it is not necessary to do this, laying these values out on separate lines like this, can be useful to seeing what each number stands for when calling the constructor for TwoHeadPointer. Lines 92-93 construct the thPtr using the values from lines 83-91, and adding in colors for the start, middle and end lines, as well as marker Ids for the start and end markers. Note that we are making everything "blue", so the marker Ids can be the same value. Line 94 draws thPtr into the svg area.

Lines 109 to 111 handle the case where the next step for a "compare" step is not a "swap". That is, the next step is another "compare". In that case, we want to move the end of thPtr to the next node. Line 109 gets the x2 attribute from thPtr.end.line, converts this to a number and stores this as x. Line 110 prints x to the console. Line 111 calls the slideEnd() method from the TwoHeadPointer class. This is not yet defined, so here is the new version of pointer.mjs:

pointer.mjs
export class Pointer {
  constructor(x1, y1, x2, y2, arrowsize, color, markerId) {
    this.svgNS = "http://www.w3.org/2000/svg";

    this.pointerDefs = document.createElementNS(this.svgNS, 'defs');
    this.arrow = document.createElementNS(this.svgNS, 'marker');
    this.arrow.setAttribute("id", markerId);
    this.arrow.setAttribute("markerWidth", arrowsize);
    this.arrow.setAttribute("markerHeight", arrowsize);
    this.arrow.setAttribute("refX", arrowsize/2);
    this.arrow.setAttribute("refY", arrowsize/2);
    this.arrow.setAttribute("orient", "auto");

    const path = document.createElementNS(this.svgNS, 'path');
    const d_val = `M 0 0 L ${arrowsize} ${arrowsize/2} L 0 ${arrowsize} z`;
    path.setAttribute("d", d_val);
    path.setAttribute("fill", color);

    this.arrow.appendChild(path);
    this.pointerDefs.appendChild(this.arrow);

    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
    this.line.setAttribute("marker-end", `url(#${markerId})`);
  }

  draw(svg_area) {
    svg_area.appendChild(this.pointerDefs);
    svg_area.appendChild(this.line);
  }
}

class Line {
  constructor(x1, y1, x2, y2, color) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
  }
}

export class TwoHeadPointer {
  constructor(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize, startcol, middlecol,
    endcol, startId, endId) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.start = new Pointer(x1, y1, x2, y2, arrowsize, startcol, startId);
    this.middle = new Line(x1, y1, x3, y3, middlecol);
    this.end = new Pointer(x3, y3, x4, y4, arrowsize, endcol, endId);
  }

  draw(svg_area) {
    svg_area.appendChild(this.start.pointerDefs);
    svg_area.appendChild(this.end.pointerDefs);
    svg_area.appendChild(this.start.line);
    svg_area.appendChild(this.middle.line);
    svg_area.appendChild(this.end.line);
  }

  slideEnd(x) {
    console.log(this.end.line);
  }
}

The new lines are 68-70. Lines 68-70 define an exploratory version of the slideEnd() method. This will eventually be used to slide the end part of the TwoHeadPointer to the position x. At this point, all this method will do is print this.end.line, the line part of the end of the TwoHeadPointer, to the console.

With these changes, let’s run the application. Here is a screen shot showing what the TwoHeadPointer object looks like. It points from the min value to the node that min is being compared with.

thPtr initial

The following shows the console output after clicking the Compare button:

console after clicking Compare button once
index.mjs:171 init called
index.mjs:71 (5) [Node, Node, Node, Node, Node]
index.mjs:73 {"step":"set","min":4,"minPos":0}
index.mjs:80 Status: Comparing 4 with 13
index.mjs:110 x 96
pointer.mjs:69 <line x1=​"146" y1=​"30" x2=​"146" y2=​"50" stroke=​"blue" stroke-width=​"2" marker-end=​"url(#blue-ptr)​">​</line>​
index.mjs:112 {"step":"compare","originalMin":4,"originalMinPos":0,"comparePos":1,"compareTo":13}
index.mjs:127 Status: min is unchanged. Comparing 4 with 1

The two output lines we are interested in is line 5 and line 6. Line 5 is printed from within the handleCompare() handler. This prints that the value of x is 96. This is x-coordinate of the node in position 1, the 13. Line 5 is printed from within the slideEnd() method from pointer.mjs. This shows that this.end.line is the following object:

<line x1=​"96" y1=​"30" x2=​"96" y2=​"50" stroke=​"blue" stroke-width=​"2" marker-end=​"url(#blue-ptr)​">​</line>​

This tells us that if we want to slide the this.end.line, we need to change the x1 and x2 attributes of this.end.line. Actually, we want the finishing point of this.middle.line to change as well, as this will be the starting point for this.end.line. With that in mind, here is the new version of pointer.mjs:

pointer.mjs
export class Pointer {
  constructor(x1, y1, x2, y2, arrowsize, color, markerId) {
    this.svgNS = "http://www.w3.org/2000/svg";

    this.pointerDefs = document.createElementNS(this.svgNS, 'defs');
    this.arrow = document.createElementNS(this.svgNS, 'marker');
    this.arrow.setAttribute("id", markerId);
    this.arrow.setAttribute("markerWidth", arrowsize);
    this.arrow.setAttribute("markerHeight", arrowsize);
    this.arrow.setAttribute("refX", arrowsize/2);
    this.arrow.setAttribute("refY", arrowsize/2);
    this.arrow.setAttribute("orient", "auto");

    const path = document.createElementNS(this.svgNS, 'path');
    const d_val = `M 0 0 L ${arrowsize} ${arrowsize/2} L 0 ${arrowsize} z`;
    path.setAttribute("d", d_val);
    path.setAttribute("fill", color);

    this.arrow.appendChild(path);
    this.pointerDefs.appendChild(this.arrow);

    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
    this.line.setAttribute("marker-end", `url(#${markerId})`);
  }

  draw(svg_area) {
    svg_area.appendChild(this.pointerDefs);
    svg_area.appendChild(this.line);
  }
}

class Line {
  constructor(x1, y1, x2, y2, color) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
  }
}

export class TwoHeadPointer {
  constructor(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize, startcol, middlecol,
    endcol, startId, endId) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.start = new Pointer(x1, y1, x2, y2, arrowsize, startcol, startId);
    this.middle = new Line(x1, y1, x3, y3, middlecol);
    this.end = new Pointer(x3, y3, x4, y4, arrowsize, endcol, endId);
  }

  draw(svg_area) {
    svg_area.appendChild(this.start.pointerDefs);
    svg_area.appendChild(this.end.pointerDefs);
    svg_area.appendChild(this.start.line);
    svg_area.appendChild(this.middle.line);
    svg_area.appendChild(this.end.line);
  }

  slideEnd(x) {
    console.log(this.end.line);
    this.end.line.setAttribute("x1", x);
    this.end.line.setAttribute("x2", x);
    this.middle.line.setAttribute("x2", x);
  }
}

The new lines are 70-72. Lines 70 and 71 set the starting and ending x-coordinates for this.end.line to be the passed value, x. Line 72 sets the ending x-coordinate for this.middle.line to x. Note that for any SVG <line> elements, (x1, y1) is the starting coordinates and (x2, y2) is the ending coordinates. Don’t confuse these parameters with the ones we used to construct a TwoHeadPointer. The two lines, this.end.line and this.middle.line are lines that are part of a TwoHeadPointer object, but those lines have their own set of parameters.

Now, we can run the application again, and click Compare up until the first "swap". The simplest way to see this working is to display an animated gif that shows thPtr's end moving to the next node as we hit Compare.

slideEnd

Click on Reload gif to replay animation.

As, you can see, clicking the Compare button causes thPtr to advance to the next node to be compared. The next task is to modify the handleCompare() and handleSwap() handler functions so that in getting ready for the swap, we hide thPtr, and then when the swap is complete, we make thPtr visible and pointing to the correct node to be compared. Let’s modify the pointer.mjs module to add a method called setVisible() that can be used to make the TwoHeadPointer object visible or hidden.

pointer.mjs
export class Pointer {
  constructor(x1, y1, x2, y2, arrowsize, color, markerId) {
    this.svgNS = "http://www.w3.org/2000/svg";

    this.pointerDefs = document.createElementNS(this.svgNS, 'defs');
    this.arrow = document.createElementNS(this.svgNS, 'marker');
    this.arrow.setAttribute("id", markerId);
    this.arrow.setAttribute("markerWidth", arrowsize);
    this.arrow.setAttribute("markerHeight", arrowsize);
    this.arrow.setAttribute("refX", arrowsize/2);
    this.arrow.setAttribute("refY", arrowsize/2);
    this.arrow.setAttribute("orient", "auto");

    const path = document.createElementNS(this.svgNS, 'path');
    const d_val = `M 0 0 L ${arrowsize} ${arrowsize/2} L 0 ${arrowsize} z`;
    path.setAttribute("d", d_val);
    path.setAttribute("fill", color);

    this.arrow.appendChild(path);
    this.pointerDefs.appendChild(this.arrow);

    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
    this.line.setAttribute("marker-end", `url(#${markerId})`);
  }

  draw(svg_area) {
    svg_area.appendChild(this.pointerDefs);
    svg_area.appendChild(this.line);
  }
}

class Line {
  constructor(x1, y1, x2, y2, color) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.line = document.createElementNS(this.svgNS, 'line');
    this.line.setAttribute("x1", x1);
    this.line.setAttribute("y1", y1);
    this.line.setAttribute("x2", x2);
    this.line.setAttribute("y2", y2);
    this.line.setAttribute("stroke", color);
    this.line.setAttribute("stroke-width", 2);
  }
}

export class TwoHeadPointer {
  constructor(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize, startcol, middlecol,
    endcol, startId, endId) {
    this.svgNS = "http://www.w3.org/2000/svg";
    this.start = new Pointer(x1, y1, x2, y2, arrowsize, startcol, startId);
    this.middle = new Line(x1, y1, x3, y3, middlecol);
    this.end = new Pointer(x3, y3, x4, y4, arrowsize, endcol, endId);
  }

  draw(svg_area) {
    svg_area.appendChild(this.start.pointerDefs);
    svg_area.appendChild(this.end.pointerDefs);
    svg_area.appendChild(this.start.line);
    svg_area.appendChild(this.middle.line);
    svg_area.appendChild(this.end.line);
  }

  slideEnd(x) {
    console.log(this.end.line);
    this.end.line.setAttribute("x1", x);
    this.end.line.setAttribute("x2", x);
    this.middle.line.setAttribute("x2", x);
  }

  setVisible(bool) {
    if (bool === true) {
      this.start.line.setAttribute("visibility", "visible");
      this.middle.line.setAttribute("visibility", "visible");
      this.end.line.setAttribute("visibility", "visible");
    } else {
      this.start.line.setAttribute("visibility", "hidden");
      this.middle.line.setAttribute("visibility", "hidden");
      this.end.line.setAttribute("visibility", "hidden");
    }
  }
}

The new lines are 75-85. Lines 75-85 define the setVisible() method for the TwoHeadPointer class. If the passed value to this method is true, lines 77-79 will set the visibility attribute to visible for all three parts of the TwoHeadPointer. If the passed value to setVisible() is false, then lines 81-83 will set the visibility attribute to hidden for all three parts of TwoHeadPointer. So, this method will allow us to set whether or not the TwoHeadPointer object is visible or hidden.

Next, we modify index.mjs to make use of the change to the TwoHeadPointer class, and to draw thPtr correctly after the swap occurs. So, in the handleCompare() handler, we check to see if the next step is a "swap". If so, we hide thPtr before that swap. In the handleSwap() handler, after the swap is done, we make thPtr visible and make the end point to the correct next node to be compared.

index.mjs
import { Node } from "./node.mjs";
import { SelectSorter } from "./select_sort.mjs";
import { TwoHeadPointer } from "./pointer.mjs";

if (document.readyState === "loading") {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

function removeChildren(elem) {
  while (elem.childNodes.length > 0) {
    elem.removeChild(elem.childNodes[0]);
  }
}

function createHandlers() {
  const status_label = document.getElementById("status_label");
  const min_label = document.getElementById("min_label");
  const minPos_label = document.getElementById("minPos_label");
  const comparisons_label = document.getElementById("comparisons_label");
  const swaps_label = document.getElementById("swaps_label");
  let comparisons_count = 0;
  let swaps_count = 0;
  const svg_area = document.getElementById("svg_area");
  let ss; // select sorter object
  let sort_steps = [];
  let node_array = [];
  let step_count = 0;
  let thPtr; // reference to the TwoHeadPointer object
  const width = 30; // width of a Node
  const height = 25; // height of a Node

  function getNodeById(id) {
    for (let i = 0; i < node_array.length; i++) {
      const nd = node_array[i];
      if (Number(nd.g.id) === id) {
        return nd;
      }
    }
  }

  function resetIds() {
    for (let i = 0; i < node_array.length; i++) {
      node_array[i].g.setAttribute("id", i);
    }
  }

  return {

    handleOk(compare_button, swap_button) {
      const numbox = document.getElementById("numbox");
      removeChildren(svg_area);
      //let nums = numbox.value.trim().split(",");
      ss = new SelectSorter(numbox.value);
      step_count = 0;
      sort_steps = ss.getSortSteps();
      const original_array = ss.getOriginalArray();
      node_array = [];
      let xStart = 30;
      let yStart = 60;
      for (let i = 0; i < original_array.length; i++) {
        const num = original_array[i];
        const nd = new Node(xStart + i*(width+20), yStart, width, height, num, i);
        node_array.push(nd); // this is from the original unsorted array
        nd.draw(svg_area);
      }
      //status_label.textContent = "Getting ready to find the min value";
      comparisons_label.textContent = comparisons_count;
      swaps_label.textContent = swaps_count;
      console.log(node_array);
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      min_label.textContent = step.min;
      minPos_label.textContent = step.minPos;
      step_count++;  // go to next step
      const next_step = sort_steps[step_count];
      let msg = `Comparing ${next_step.originalMin} with ${next_step.compareTo}`;
      status_label.textContent = msg;
      console.log('Status:', status_label.textContent);
      compare_button.disabled = false;
      swap_button.disabled = true;
      let x1 = 38;
      let y1 = 30;
      let x2 = 38;
      let y2 = 5;
      let x3 = 96;
      let y3 = 30;
      let x4 = 96;
      let y4 = 50;
      let arrowsize = 5;
      thPtr = new TwoHeadPointer(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize,
        "blue", "blue", "blue", "blue-ptr", "blue-ptr");
      thPtr.draw(svg_area);
    },

    handleCompare(compare_button, swap_button) {
      if (!ss) { return; }
      comparisons_count++;
      comparisons_label.textContent = comparisons_count;
      const step = sort_steps[step_count];
      const next_step = sort_steps[step_count + 1];
      //let msg = `comparing ${step.originalMin} with ${step.compareTo}`
      if (next_step.step === "swap") {
        console.log(JSON.stringify(step));
        thPtr.setVisible(false);
        compare_button.disabled = true;
        swap_button.disabled = false;
      } else {
        const x = Number(thPtr.end.line.getAttribute("x2"));
        console.log('x', x);
        thPtr.slideEnd(x + width + 20);
        console.log(JSON.stringify(step));
      }
      let msg = "";
      if (step.minChanged === true) {
        msg += "min and minPos are changed. ";
        min_label.textContent = step.min;
        minPos_label.textContent = step.minPos;
      } else {
        msg += "min is unchanged. ";
      }
      msg += `Comparing ${next_step.originalMin} with ${next_step.compareTo}`;
      if (next_step.step === "swap") {
        msg = `Swapping pos: ${next_step.pos1} with pos: ${next_step.pos2}`;
      }
      status_label.textContent = msg;
      console.log('Status:', status_label.textContent);
      step_count++;
    },

    async handleSwap(compare_button, swap_button) {
      if (!ss) {return};
      swaps_count++;
      swaps_label.textContent = swaps_count;
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      const nd1 = getNodeById(step.pos1);
      const nd2 = getNodeById(step.pos2);
      if (step.pos1 !== step.pos2) {
        await nd1.swap(nd2, 700);
        const temp = node_array[step.pos1];
        node_array[step.pos1] = node_array[step.pos2];
        node_array[step.pos2] = temp;
        resetIds();
      } else {
        console.log("node already in correct place");
      }
      if (step_count < sort_steps.length - 1) {
        step_count++;  // advance to "set" step
        const set_step = sort_steps[step_count];
        console.log(JSON.stringify(set_step));
        const next_step = sort_steps[step_count + 1];
        const nd = getNodeById(set_step.minPos + 1); // node after the node in min Pos
        const x = Number(nd.g.getAttribute("data-x"));
        console.log('x', x);
        thPtr.setVisible(true);
        thPtr.slideEnd(x + width/2); // x would be coordinate of left edge of node
        status_label.textContent = `comparing ${set_step.min} with ${next_step.compareTo}.`;
        console.log('Status:',status_label.textContent);
        min_label.textContent = set_step.min;
        minPos_label.textContent = set_step.minPos;
        step_count++;  // advance to "compare" step
        compare_button.disabled = false;
        swap_button.disabled = true;
      } else {
        status_label.textContent = "All done";
        compare_button.disabled = true;
        swap_button.disabled = true;
      }
    }

  }
}

function init() {
  console.log('init called');
  const handlers = createHandlers();
  const ok_button = document.getElementById("ok_button");
  const compare_button = document.getElementById("compare_button");
  const swap_button = document.getElementById("swap_button");
  ok_button.addEventListener('click', () => {
    handlers.handleOk(compare_button, swap_button);
  });
  compare_button.addEventListener('click', () => {
    handlers.handleCompare(compare_button, swap_button);
  });
  swap_button.addEventListener('click', () => {
    handlers.handleSwap(compare_button, swap_button);
  });
}

The new lines are 106 and 154-158. Line 106 hides thPtr before the swap takes place. Lines 154-158 take place after the swap has occurred. Line 154 gets the node right after the node in minPos. Line 155 uses the data-x attribute for that node to get the x-coordinate of that node. Recall that data-x will be the x-coordinate at the left edge of the node. So, on line 1ine 158, we add width/2 to x so that we get the x-value at the center of that next node. Line 156 just prints the value of x to the console for debugging purposes. Line 157 makes thPtr visible and line 158 makes the end of thPtr point to the center of the next node.

To see how the application works now, we can view an animated gif that will perform the sort. The two things to pay attention to in this gif, is the status_label and the TwoHeadPointer object. Those two things should be synchronized with each other.

sort with pointer

Click on Reload gif to replay animation.

Using color for the Node objects

Another thing we can do is to change the stroke color for the Node objects. This will allow us to show which nodes have already been sorted, and also show which nodes are going to be swapped. To do this, we can start by modifying the node.mjs module by adding a method called higlight() that will change the stroke color for the <rect> part of the node. Here is the new version of node.mjs:

node.mjs
export class Node {
  constructor(x, y, width, height, label, id) {
    this.svgNS = "http://www.w3.org/2000/svg";

    this.g = document.createElementNS(this.svgNS, "g");
    this.g.id = id;
    this.g.setAttribute("transform", `translate(${x}, ${y})`);
    this.g.setAttribute("data-x", x);
    this.g.setAttribute("data-y", y);

    this.rect = document.createElementNS(this.svgNS, "rect");
    this.rect.setAttribute("x", 0);
    this.rect.setAttribute("y", 0);
    this.rect.setAttribute("width", width);
    this.rect.setAttribute("height", height);
    this.rect.setAttribute("fill", "#eeeeee");
    this.rect.setAttribute("stroke", "black");
    this.rect.setAttribute("stroke-width", 2);

    this.text = document.createElementNS(this.svgNS, "text");
    this.text.setAttribute("x", width/2);
    this.text.setAttribute("y", height/2);
    this.text.setAttribute("text-anchor", "middle");
    this.text.setAttribute("dominant-baseline", "middle");
    this.text.setAttribute("fill", "black");
    this.text.textContent = label;

    this.g.appendChild(this.rect);
    this.g.appendChild(this.text);
  }

  draw(svg_area) {
    svg_area.appendChild(this.g);
  }

  highlight(color) {
    this.rect.setAttribute("stroke",color);
  }

  moveTo(endX, endY, duration) {
    return new Promise((resolve) => {
      let startTime = null;
      const startX = Number(this.g.getAttribute("data-x"));
      const startY = Number(this.g.getAttribute("data-y"));
      let currentX = startX;
      let currentY = startY;

      const step = (timestamp) => {
        if (startTime === null) {
          startTime = timestamp;
        }
        let progress = Math.min((timestamp - startTime)/duration, 1);

        currentX = startX + (endX - startX)*progress;
        currentY = startY + (endY - startY)*progress;

        this.g.setAttribute("transform", `translate(${currentX}, ${currentY})`);

        if (progress < 1) {
          requestAnimationFrame(step);
        } else {
          this.g.setAttribute("data-x", endX);
          this.g.setAttribute("data-y", endY);
          console.log('this.g', this.g);
          resolve(); // animation is done
        }
      }
      requestAnimationFrame(step);
    });
  }

  swap(nd, duration) {
    return new Promise((resolve) => {
      let startTime = null;
      const x1 = Number(this.g.getAttribute("data-x"));
      const y1 = Number(this.g.getAttribute("data-y"));
      const x2 = Number(nd.g.getAttribute("data-x"));
      const y2 = Number(nd.g.getAttribute("data-y"));
      let currentX1 = x1;
      let currentY1 = y1;
      let currentX2 = x2;
      let currentY2 = y2;
      const endX1 = x2;
      const endY1 = y2;
      const endX2 = x1;
      const endY2 = y1;

      const step = (timestamp) => {
        if (startTime === null) {
          startTime = timestamp;
        }
        let progress = Math.min((timestamp - startTime)/duration, 1);

        currentX1 = x1 + (x2 - x1)*progress;
        currentY1 = y1 + (y2 - y1)*progress;
        currentX2 = x2 + (x1 - x2)*progress;
        currentY2 = y2 + (y1 - y2)*progress;

        this.g.setAttribute("transform", `translate(${currentX1}, ${currentY1})`);
        nd.g.setAttribute("transform", `translate(${currentX2}, ${currentY2})`);

        if (progress < 1) {
          requestAnimationFrame(step);
        }
        else {
          this.g.setAttribute("data-x", x2);
          this.g.setAttribute("data-y", y2);
          nd.g.setAttribute("data-x", x1);
          nd.g.setAttribute("data-y", y1);
          resolve();
        }
      }
      requestAnimationFrame(step);
    });
  }

}

The new lines are 36-38. Lines 36-38 define the highlight() method. This can be used to change the stroke color of the <rect> part of a Node object.

To make use of the new highlight() method for the node.mjs module, we can modify index.mjs. When nodes are swapped, the node that gets swapped into step.pos1 can be higlighted in "red". So, the parts of the array that are already in sorted position, will be shown in red. Here is the adjustment needed to the handleSwap() handler for index.mjs:

index.mjs showing only the handleSwap() handler
    async handleSwap(compare_button, swap_button) {
      if (!ss) {return};
      swaps_count++;
      swaps_label.textContent = swaps_count;
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      const nd1 = getNodeById(step.pos1);
      const nd2 = getNodeById(step.pos2);
      if (step.pos1 !== step.pos2) {
        await nd1.swap(nd2, 700);
        const temp = node_array[step.pos1];
        node_array[step.pos1] = node_array[step.pos2];
        node_array[step.pos2] = temp;
        resetIds();
      } else {
        console.log("node already in correct place");
      }
      if (step_count < sort_steps.length - 1) {
        step_count++;  // advance to "set" step
        const set_step = sort_steps[step_count];
        console.log(JSON.stringify(set_step));
        const next_step = sort_steps[step_count + 1];
        const nd = getNodeById(set_step.minPos + 1); // node after the node in min Pos
        const x = Number(nd.g.getAttribute("data-x"));
        console.log('x', x);
        thPtr.setVisible(true);
        thPtr.slideEnd(x + width/2); // x would be coordinate of left edge of node
        status_label.textContent = `comparing ${set_step.min} with ${next_step.compareTo}.`;
        console.log('Status:',status_label.textContent);
        min_label.textContent = set_step.min;
        minPos_label.textContent = set_step.minPos;
        step_count++;  // advance to "compare" step
        compare_button.disabled = false;
        swap_button.disabled = true;
        getNodeById(step.pos1).highlight("red");
      } else {
        status_label.textContent = "All done";
        compare_button.disabled = true;
        swap_button.disabled = true;
        getNodeById(step.pos1).highlight("red");
        getNodeById(step.pos1 + 1).highlight("red");
      }
    }

This is only a part of the index.mjs file. So do not copy and paste this to replace your index.mjs. At the end of the changes we are working on, I will put up the entire index.mjs file. The new lines here are 166 and 171-172. Line 166 will make the node that was swapped into the correct position highlighed in "red". This is to show that we are done (or stopped processing) that node as it is in the sorted position.

Lines 171 and 172 handle the case where the sort is completely done. In that case lines 171 and 172 highlight the last two elements in red.

You can run the application now and see how the red highlighting works. If you do run the application, you can see the sorted parts of the array being highlighted in red. What would be nice, is that on the step before the swap takes place we highlight the nodes to be swapped. This requires changing some code in both the handleCompare() handler and the handleSwap() handler. Here is the new code for those two handlers in index.mjs.

index.mjs showing only the handleCompare() and handleSwap() handlers
    handleCompare(compare_button, swap_button) {
      if (!ss) { return; }
      comparisons_count++;
      comparisons_label.textContent = comparisons_count;
      const step = sort_steps[step_count];
      const next_step = sort_steps[step_count + 1];
      //let msg = `comparing ${step.originalMin} with ${step.compareTo}`
      if (next_step.step === "swap") {
        console.log(JSON.stringify(step));
        getNodeById(next_step.pos1).highlight("lime");
        getNodeById(next_step.pos2).highlight("lime");
        thPtr.setVisible(false);
        compare_button.disabled = true;
        swap_button.disabled = false;
      } else {
        const x = Number(thPtr.end.line.getAttribute("x2"));
        console.log('x', x);
        thPtr.slideEnd(x + width + 20);
        console.log(JSON.stringify(step));
      }
      let msg = "";
      if (step.minChanged === true) {
        msg += "min and minPos are changed. ";
        min_label.textContent = step.min;
        minPos_label.textContent = step.minPos;
      } else {
        msg += "min is unchanged. ";
      }
      msg += `Comparing ${next_step.originalMin} with ${next_step.compareTo}`;
      if (next_step.step === "swap") {
        msg = `Swapping pos: ${next_step.pos1} with pos: ${next_step.pos2}`;
      }
      status_label.textContent = msg;
      console.log('Status:', status_label.textContent);
      step_count++;
    },

    async handleSwap(compare_button, swap_button) {
      if (!ss) {return};
      swaps_count++;
      swaps_label.textContent = swaps_count;
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      const nd1 = getNodeById(step.pos1);
      const nd2 = getNodeById(step.pos2);
      if (step.pos1 !== step.pos2) {
        await nd1.swap(nd2, 700);
        const temp = node_array[step.pos1];
        node_array[step.pos1] = node_array[step.pos2];
        node_array[step.pos2] = temp;
        resetIds();
      } else {
        console.log("node already in correct place");
      }
      if (step_count < sort_steps.length - 1) {
        step_count++;  // advance to "set" step
        const set_step = sort_steps[step_count];
        console.log(JSON.stringify(set_step));
        const next_step = sort_steps[step_count + 1];
        const nd = getNodeById(set_step.minPos + 1); // node after the node in min Pos
        const x = Number(nd.g.getAttribute("data-x"));
        console.log('x', x);
        thPtr.setVisible(true);
        thPtr.slideEnd(x + width/2); // x would be coordinate of left edge of node
        status_label.textContent = `comparing ${set_step.min} with ${next_step.compareTo}.`;
        console.log('Status:',status_label.textContent);
        min_label.textContent = set_step.min;
        minPos_label.textContent = set_step.minPos;
        step_count++;  // advance to "compare" step
        compare_button.disabled = false;
        swap_button.disabled = true;
        getNodeById(step.pos1).highlight("red");
        getNodeById(step.pos2).highlight("black");
      } else {
        status_label.textContent = "All done";
        compare_button.disabled = true;
        swap_button.disabled = true;
        getNodeById(step.pos1).highlight("red");
        getNodeById(step.pos1 + 1).highlight("red");
      }
    }

This is only a part of the index.mjs file. So do not copy and paste this to replace your index.mjs. At the end of the changes we are working on, I will put up the entire index.mjs file. The new lines here are 106-107 and 169. Lines 106 and 107 inside the handleCompare() handler will highlight the nodes that are to be swapped in "lime", a bright green color. Line 169 inside the handleSwap() handler will change the other node that was swapped back to a "black" highlight.

To see the changes made here and the previous changes, we can look at the following animated gif file. In viewing this file, the most important thing to look for is the color highlighting of the nodes. Nodes that are already in their final sorted position will be highlighted in "red". Nodes that are about to be swapped will be highlighted in "lime" green.

sort with highlighting

Click on Reload gif to replay animation.

Adding a bounce() method to the node.mjs module

One last thing that can be done, is to add a bounce() method to the node.mjs module. The bounce() method can be used when a "swap" step is going to leave the node in place. So, if a "swap" step will just leave the node in place, only one node will be highlighted in "lime" green. Instead of just jumping to the next step, we can use bounce() to make the node bounce up and down, to signify that it is staying in place. Here is the new version of node.mjs:

node.mjs
export class Node {
  constructor(x, y, width, height, label, id) {
    this.svgNS = "http://www.w3.org/2000/svg";

    this.g = document.createElementNS(this.svgNS, "g");
    this.g.id = id;
    this.g.setAttribute("transform", `translate(${x}, ${y})`);
    this.g.setAttribute("data-x", x);
    this.g.setAttribute("data-y", y);

    this.rect = document.createElementNS(this.svgNS, "rect");
    this.rect.setAttribute("x", 0);
    this.rect.setAttribute("y", 0);
    this.rect.setAttribute("width", width);
    this.rect.setAttribute("height", height);
    this.rect.setAttribute("fill", "#eeeeee");
    this.rect.setAttribute("stroke", "black");
    this.rect.setAttribute("stroke-width", 2);

    this.text = document.createElementNS(this.svgNS, "text");
    this.text.setAttribute("x", width/2);
    this.text.setAttribute("y", height/2);
    this.text.setAttribute("text-anchor", "middle");
    this.text.setAttribute("dominant-baseline", "middle");
    this.text.setAttribute("fill", "black");
    this.text.textContent = label;

    this.g.appendChild(this.rect);
    this.g.appendChild(this.text);
  }

  draw(svg_area) {
    svg_area.appendChild(this.g);
  }

  highlight(color) {
    this.rect.setAttribute("stroke", color);
  }

  async bounce() {
    const x1 = Number(this.g.getAttribute("data-x"));
    const y1 = Number(this.g.getAttribute("data-y"));
    await this.moveTo(x1, y1 - 25, 400);
    await this.moveTo(x1, y1, 400);
  }

  moveTo(endX, endY, duration) {
    return new Promise((resolve) => {
      let startTime = null;
      const startX = Number(this.g.getAttribute("data-x"));
      const startY = Number(this.g.getAttribute("data-y"));
      let currentX = startX;
      let currentY = startY;

      const step = (timestamp) => {
        if (startTime === null) {
          startTime = timestamp;
        }
        let progress = Math.min((timestamp - startTime)/duration, 1);

        currentX = startX + (endX - startX)*progress;
        currentY = startY + (endY - startY)*progress;

        this.g.setAttribute("transform", `translate(${currentX}, ${currentY})`);

        if (progress < 1) {
          requestAnimationFrame(step);
        } else {
          this.g.setAttribute("data-x", endX);
          this.g.setAttribute("data-y", endY);
          //console.log('this.g', this.g);
          resolve(); // animation is done
        }
      }
      requestAnimationFrame(step);
    });
  }

  swap(nd, duration) {
    return new Promise((resolve) => {
      let startTime = null;
      const x1 = Number(this.g.getAttribute("data-x"));
      const y1 = Number(this.g.getAttribute("data-y"));
      const x2 = Number(nd.g.getAttribute("data-x"));
      const y2 = Number(nd.g.getAttribute("data-y"));
      let currentX1 = x1;
      let currentY1 = y1;
      let currentX2 = x2;
      let currentY2 = y2;
      const endX1 = x2;
      const endY1 = y2;
      const endX2 = x1;
      const endY2 = y1;

      const step = (timestamp) => {
        if (startTime === null) {
          startTime = timestamp;
        }
        let progress = Math.min((timestamp - startTime)/duration, 1);

        currentX1 = x1 + (x2 - x1)*progress;
        currentY1 = y1 + (y2 - y1)*progress;
        currentX2 = x2 + (x1 - x2)*progress;
        currentY2 = y2 + (y1 - y2)*progress;

        this.g.setAttribute("transform", `translate(${currentX1}, ${currentY1})`);
        nd.g.setAttribute("transform", `translate(${currentX2}, ${currentY2})`);

        if (progress < 1) {
          requestAnimationFrame(step);
        }
        else {
          this.g.setAttribute("data-x", x2);
          this.g.setAttribute("data-y", y2);
          nd.g.setAttribute("data-x", x1);
          nd.g.setAttribute("data-y", y1);
          resolve();
        }
      }
      requestAnimationFrame(step);
    });
  }

}

The new lines are 40-45. Lines 40-45 define the bounce() method. This method is declared as async because we need to use await on lines 43 and 44. Lines 41 and 42 obtain the x and y coordinates of the Node, respectively. On line 43 we are moving the node up, by decreasing the y value. We need to await this execution, so that the Node can move all the way up by 25 pixels, before line 44 is executed to move the Node back to its original position. On line 43 and 44, the second argument to the moveTo() method is 400. This means that the animation will take about 0.4 seconds to go up and to go down.

We need to modify index.mjs to make use of the new bounce() method we added to the node.mjs module. Here is the new version of index.mjs. Even though the number of lines changed is just a few, the whole file is shown here as this is the last version for this lesson.

index.mjs
import { Node } from "./node.mjs";
import { SelectSorter } from "./select_sort.mjs";
import { TwoHeadPointer } from "./pointer.mjs";

if (document.readyState === "loading") {
  document.addEventListener('DOMContentLoaded', init);
} else {
  init();
}

function removeChildren(elem) {
  while (elem.childNodes.length > 0) {
    elem.removeChild(elem.childNodes[0]);
  }
}

function createHandlers() {
  const status_label = document.getElementById("status_label");
  const min_label = document.getElementById("min_label");
  const minPos_label = document.getElementById("minPos_label");
  const comparisons_label = document.getElementById("comparisons_label");
  const swaps_label = document.getElementById("swaps_label");
  let comparisons_count = 0;
  let swaps_count = 0;
  const svg_area = document.getElementById("svg_area");
  let ss; // select sorter object
  let sort_steps = [];
  let node_array = [];
  let step_count = 0;
  let thPtr; // reference to the TwoHeadPointer object
  const width = 30; // width of a Node
  const height = 25; // height of a Node

  function getNodeById(id) {
    for (let i = 0; i < node_array.length; i++) {
      const nd = node_array[i];
      if (Number(nd.g.id) === id) {
        return nd;
      }
    }
  }

  function resetIds() {
    for (let i = 0; i < node_array.length; i++) {
      node_array[i].g.setAttribute("id", i);
    }
  }

  return {

    handleOk(compare_button, swap_button) {
      const numbox = document.getElementById("numbox");
      removeChildren(svg_area);
      //let nums = numbox.value.trim().split(",");
      ss = new SelectSorter(numbox.value);
      step_count = 0;
      sort_steps = ss.getSortSteps();
      const original_array = ss.getOriginalArray();
      node_array = [];
      let xStart = 30;
      let yStart = 60;
      for (let i = 0; i < original_array.length; i++) {
        const num = original_array[i];
        const nd = new Node(xStart + i*(width+20), yStart, width, height, num, i);
        node_array.push(nd); // this is from the original unsorted array
        nd.draw(svg_area);
      }
      //status_label.textContent = "Getting ready to find the min value";
      comparisons_label.textContent = comparisons_count;
      swaps_label.textContent = swaps_count;
      console.log(node_array);
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      min_label.textContent = step.min;
      minPos_label.textContent = step.minPos;
      step_count++;  // go to next step
      const next_step = sort_steps[step_count];
      let msg = `Comparing ${next_step.originalMin} with ${next_step.compareTo}`;
      status_label.textContent = msg;
      console.log('Status:', status_label.textContent);
      compare_button.disabled = false;
      swap_button.disabled = true;
      let x1 = 38;
      let y1 = 30;
      let x2 = 38;
      let y2 = 5;
      let x3 = 96;
      let y3 = 30;
      let x4 = 96;
      let y4 = 50;
      let arrowsize = 5;
      thPtr = new TwoHeadPointer(x1, y1, x2, y2, x3, y3, x4, y4, arrowsize,
        "blue", "blue", "blue", "blue-ptr", "blue-ptr");
      thPtr.draw(svg_area);
    },

    handleCompare(compare_button, swap_button) {
      if (!ss) { return; }
      comparisons_count++;
      comparisons_label.textContent = comparisons_count;
      const step = sort_steps[step_count];
      const next_step = sort_steps[step_count + 1];
      //let msg = `comparing ${step.originalMin} with ${step.compareTo}`
      if (next_step.step === "swap") {
        console.log(JSON.stringify(step));
        getNodeById(next_step.pos1).highlight("lime");
        getNodeById(next_step.pos2).highlight("lime");
        thPtr.setVisible(false);
        compare_button.disabled = true;
        swap_button.disabled = false;
      } else {
        const x = Number(thPtr.end.line.getAttribute("x2"));
        console.log('x', x);
        thPtr.slideEnd(x + width + 20);
        console.log(JSON.stringify(step));
      }
      let msg = "";
      if (step.minChanged === true) {
        msg += "min and minPos are changed. ";
        min_label.textContent = step.min;
        minPos_label.textContent = step.minPos;
      } else {
        msg += "min is unchanged. ";
      }
      msg += `Comparing ${next_step.originalMin} with ${next_step.compareTo}`;
      if (next_step.step === "swap") {
        msg = `Swapping pos: ${next_step.pos1} with pos: ${next_step.pos2}`;
      }
      status_label.textContent = msg;
      console.log('Status:', status_label.textContent);
      step_count++;
    },

    async handleSwap(compare_button, swap_button) {
      if (!ss) {return};
      swaps_count++;
      swaps_label.textContent = swaps_count;
      const step = sort_steps[step_count];
      console.log(JSON.stringify(step));
      const nd1 = getNodeById(step.pos1);
      const nd2 = getNodeById(step.pos2);
      if (step.pos1 !== step.pos2) {
        await nd1.swap(nd2, 700);
        const temp = node_array[step.pos1];
        node_array[step.pos1] = node_array[step.pos2];
        node_array[step.pos2] = temp;
        resetIds();
      } else {
        console.log("node already in correct place");
        await nd1.bounce();
      }
      if (step_count < sort_steps.length - 1) {
        step_count++;  // advance to "set" step
        const set_step = sort_steps[step_count];
        console.log(JSON.stringify(set_step));
        const next_step = sort_steps[step_count + 1];
        const nd = getNodeById(set_step.minPos + 1); // node after the node in min Pos
        const x = Number(nd.g.getAttribute("data-x"));
        console.log('x', x);
        thPtr.setVisible(true);
        thPtr.slideEnd(x + width/2); // x would be coordinate of left edge of node
        status_label.textContent = `comparing ${set_step.min} with ${next_step.compareTo}.`;
        console.log('Status:',status_label.textContent);
        min_label.textContent = set_step.min;
        minPos_label.textContent = set_step.minPos;
        step_count++;  // advance to "compare" step
        compare_button.disabled = false;
        swap_button.disabled = true;
        getNodeById(step.pos1).highlight("red");
        if (step.pos1 !== step.pos2) {
          getNodeById(step.pos2).highlight("black");
        }
      } else {
        status_label.textContent = "All done";
        compare_button.disabled = true;
        swap_button.disabled = true;
        getNodeById(step.pos1).highlight("red");
        getNodeById(step.pos1 + 1).highlight("red");
      }
    }

  }
}

function init() {
  console.log('init called');
  const handlers = createHandlers();
  const ok_button = document.getElementById("ok_button");
  const compare_button = document.getElementById("compare_button");
  const swap_button = document.getElementById("swap_button");
  ok_button.addEventListener('click', () => {
    handlers.handleOk(compare_button, swap_button);
  });
  compare_button.addEventListener('click', () => {
    handlers.handleCompare(compare_button, swap_button);
  });
  swap_button.addEventListener('click', () => {
    handlers.handleSwap(compare_button, swap_button);
  });
}

Line 150, 170 and 172 are the changed lines. For line 150, we use await so that the bounce() movement completes before going to the next lines of instruction. Line 170 and 172 create a selection statement around line 171. So, the "other" node, in the swap, is only changed to black if the nodes actually change position. So, if a node is already in place, we want that node to be highlighted in "red".

The following animated gif shows the whole sort for an input string of "4,13,7,22,6".

sort complete

Click on Reload gif to replay animation.

Summary

  • Created pointer.mjs module - The pointer.mjs module defined the Pointer and TwoHeadPointer classes. Our application only used the TwoHeadPointer class directly, but the TwoHeadPointer class made use of the Pointer class in its constructor. When you create modules, it is a good practice to define your classes making use of other classes in the module if possible. This leads to shorter code, that is easier to debug.

  • Modified node.mjs module - The node.mjs module was modified by adding the highlight() method and the bounce() method. The highlight() method was used to highlight the nodes that were already in sorted position in red. The highlight() method was also used to show which nodes were going to be swapped by higlighting them in lime green. The bounce() method was use to animate a node that was going to stay in place with a swap. This is a node that is already in the sorted position. The bounce() method was used to illustrate that the node did not have to move anywhere else, because it was already in sorted position.

  • Adding graphics to the user interface - In the previous lesson, we used several <label> elements to show the status of the sorting, as well as the min and minPos values. But, in this lesson we made use of graphics to enhance the user experience. So, we made use of a TwoHeadPointer to point to the node that was being compared to the min value. Even though the status_label spelled this out, having the arrow draws the user’s attention more effectively in many cases. The use of colors to highlight the node also enhanced the user experience, by showing which nodes were already sorted, and by showing exactly which nodes were going to be swapped.

  • Writing demonstration applications - These three lessons, Creating User Interactive Applications Part 1, 2 and 3 were used to create what I call a demonstration application. This is an application that demonstrates some process to the user. If the application allows the user to input values, like the array to be sorted in this example, it can improve the ability of the application to teach something. That is because the user can change the values and rerun the application. So, if some part of the process was not clear the first time the user runs the application, being able to rerun the application with different values can help to make things more clear. The user interface for such an application is very important. Anything you can use like status labels, animation, graphics and buttons, can make the application a more effective teaching tool.

  • User Interactive applications - This demonstration application is an example of a User Interactive application. As you hopefully have a feel for, is that using web applications with JavaScript can make the user interface more engaging to the user. So, if you have an application where you want to teach something, or sell something, or simply inform people, you can see how being able to write web applications with JavaScript is a useful skill.