Paul Cowan

Nomadic cattle rustler and inventor of the electric lasso

Resize to Scale With d3.js

I am available for work right now, if you are interested then email me directly.

Following on from my last two posts, Perpendicular Bisectors of a Triangle With d3.js and Altitude of a Triangle With d3.js, I want to document how I ensured that my svg tranformation is resized to scale during a resize event or if the user selection can change.

Below is the end result of the last two blog posts:

You can see the result at this url.

You can change the current triangle effects by changing the radio buttons at the top. You can also drag and drop the triangle vertices by dragging the red circles at each triangle endpoint or vertex. This led to an interesting problem, which was how to how to maintain the current state or coordinates of all the shapes when the user selects a new effect from the radio buttons.

Another, more challening problem was to make sure that everything resized to the current ratio or scale if the browser window is resized. If you go to this url and resize the browser, you can see that everything re-renders nicely to scale. This does not happen out of the box. You need to code for this eventuality.

The bulk of the work takes place in the render method below:

render.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
function render(state = {}) {
  if(state.resizeFunc) {
    window.removeEventListener("resize", state.resizeFunc);
  }

  const viewportDimensions = availableViewPort();

  const availableHeight = viewportDimensions.h - 50;
  const availableWidth = availableHeight * 1.32;

  const margin = {top: 20, right: 100, bottom: 30, left: 100},
        width = availableWidth - margin.left - margin.right,
        height = availableHeight - margin.top - margin.bottom;

  d3.select("body").select("svg").remove();

  d3.selectAll('label').remove();
  d3.selectAll('input[type=radio]').remove();

  const xScale = d3.scale.linear()
          .domain([0, 20])
          .range([0, width]);

  const yScale = d3.scale.linear()
          .domain([0, 20])
          .range([height, 0]);

  let points;

  if(state.points) {
    points = {
      a: {
        x: xScale(state.xScale.invert(state.points.a.x)),
        y: yScale(state.yScale.invert(state.points.a.y))
      },
      b: {
        x: xScale(state.xScale.invert(state.points.b.x)),
        y: yScale(state.yScale.invert(state.points.b.y))
      },
      c: {
        x: xScale(state.xScale.invert(state.points.c.x)),
        y: yScale(state.yScale.invert(state.points.c.y))
      }
    };
  } else {
    points = {
      a: {x: xScale(0), y: yScale(0)},
      b: {x: xScale(6), y: yScale(18)},
      c: {x: xScale(16), y: yScale(2)}
    };
  }

  const xAxis = d3.svg.axis()
          .scale(xScale)
          .orient("bottom");

  const yAxis = d3.svg.axis()
          .scale(yScale)
          .orient("left");

  const svg = d3.select("body").append("svg")
          .attr("width", width + margin.left + margin.right)
          .attr("height", height + margin.top + margin.bottom)
          .append("g")
          .attr("transform", "translate(" + margin.left + "," + margin.top + ")");

  svg.append('g')
    .attr('class', 'x axis')
    .attr("transform", "translate(0," + height + ")")
    .call(xAxis);

  svg.append('g')
    .attr('class', 'y axis')
    .call(yAxis);

  const area = {
    xScale: xScale,
    yScale: yScale
  };

  area.currentEffect = state.currentEffect || drawMedians;

  area.points = points;

  addRadioButtons(area);

  const g = svg.append('g');

  area.g = g;
  area.svg = svg;

  const vertices = [
    {point: area.points.a, label: 'a'},
    {point: area.points.b, label: 'b'},
    {point: area.points.c, label: 'c'}
  ];

  drawTriangle(points, g);

  addCurrentEffects(area);

  addPointLabels(area, vertices);

  addGrabbers(area, vertices);

  const resizeFunc = render.bind(null, area);

  area.resizeFunc = resizeFunc;

  window.addEventListener("resize", area.resizeFunc);
}

The render function as you might expect, creates the svg document and renders all the shapes onto their specific coordinates as I outlined in the previous blog posts here and here. I use this function to both initially draw the shapes and also as the function that is attached to the resize event.

Below is the end of the render function that creates a hash that will keep track of the current state of the document or the coordinates of the all the shapes at any given time.

render2.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
  const area = {
    xScale: xScale,
    yScale: yScale
  };

  area.currentEffect = state.currentEffect || drawMedians;

  area.points = points;

  addRadioButtons(area);

  const g = svg.append('g');

  area.g = g;
  area.svg = svg;

  const vertices = [
    {point: area.points.a, label: 'a'},
    {point: area.points.b, label: 'b'},
    {point: area.points.c, label: 'c'}
  ];

  drawTriangle(points, g);

  addCurrentEffects(area);

  addPointLabels(area, vertices);

  addGrabbers(area, vertices);

  const resizeFunc = render.bind(null, area);

  area.resizeFunc = resizeFunc;

  window.addEventListener("resize", area.resizeFunc);

Lines 1 to 3 create a the hash and assign the xScale and yScale d3 scale objects that allow you to deal with a finer granuated scale than pixels. The x and y axis in the documment were created using these scale objects and you can think in terms of placing these objects at coordinates on these scales, e.g. (1, 1).

Lines 6 to 29 assign properties to this area hash such as the vertices of the triangle that will be used to read and write to when drawing the shapes. I pass this structure into most functions.

Line 31 uses the lesser known partial application properties of the bind function to create a new version of the render function. This partial function when called, will always be called with the area hash as an argument, that contains all the information we need to reconstruct the document. Line 33 adds this function to the hash, we will use this to remove the event listener each time it is called or else there will be a memory leak. LIne 35 creates an event listener for the resize event and assigns the new version of render to this event.

The beginning of the render function below uses the new es6 default paramaters feature to allow render to be called with no arguments or called from the resize event with an argument. If it is called in response to a resize event then there will be a state argument. Lines 2 to 5 remove the event listener each time it is called.

render3.js
1
2
3
4
function render(state = {}) {
  if(state.resizeFunc) {
    window.removeEventListener("resize", state.resizeFunc);
  }

Everytime render is called, I create new xScale and yScale objects that reflect the current window size on line 15 - 21 below:

render5.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
  const viewportDimensions = availableViewPort();

  const availableHeight = viewportDimensions.h - 50;
  const availableWidth = availableHeight * 1.32;

  const margin = {top: 20, right: 100, bottom: 30, left: 100},
        width = availableWidth - margin.left - margin.right,
        height = availableHeight - margin.top - margin.bottom;

  d3.select("body").select("svg").remove();

  d3.selectAll('label').remove();
  d3.selectAll('input[type=radio]').remove();

  const xScale = d3.scale.linear()
          .domain([0, 20])
          .range([0, width]);

  const yScale = d3.scale.linear()
          .domain([0, 20])
          .range([height, 0]);

Below is the code that will reassign the points of the triangle to scale from the state hash:

render3.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  if(state.points) {
    points = {
      a: {
        x: xScale(state.xScale.invert(state.points.a.x)),
        y: yScale(state.yScale.invert(state.points.a.y))
      },
      b: {
        x: xScale(state.xScale.invert(state.points.b.x)),
        y: yScale(state.yScale.invert(state.points.b.y))
      },
      c: {
        x: xScale(state.xScale.invert(state.points.c.x)),
        y: yScale(state.yScale.invert(state.points.c.y))
      }
    };
  } else {
    points = {
      a: {x: xScale(0), y: yScale(0)},
      b: {x: xScale(6), y: yScale(18)},
      c: {x: xScale(16), y: yScale(2)}
    };
  }

If we have a state hash then the invert method of the scale objects is used to get the value in pixels before using the scale to recreate the new x and y coordinates that are in scale with the new browser dimensions.

I also use partial application when adding the drag and drop event to the red circles at the vertices of the triangle on line 22 of the below:

drag.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function addPointLabels(area, vertices) {
  area.g.selectAll('text')
    .data(vertices)
    .enter().append('text')
    .attr("x", function(d){return d.point.x + 10;})
    .attr("y", function(d){return d.point.y + 10;})
    .attr('class', function(d) {return "label " + d.label;})
    .text( function(d) {
      const x = Math.round(area.xScale.invert(d.point.x));
      const y = Math.round(area.yScale.invert(d.point.y));

      return `${d.label.toUpperCase()} (${x}, ${y})`;
    })
    .attr("font-family", "sans-serif")
    .attr("font-size", "24px")
    .attr("fill", "black");
}

function addGrabbers(area, vertices) {
  const drag = d3.behavior
        .drag()
        .on("drag", draggable.bind(null, area));

After all this, I take great pleasure in resizing the window and watching everything beautifully scale.

You can checkout the github repo that contains all the code for this.

I am available for work right now, if you are interested then email me directly.

Comments