Home Reference Source

scripts/experiment-properties/graphing/d3-base-plots/scatter_plot.js

export {create_scatter_plot, plot_scatter_points};

var BUFFER = 60;
var RANGE_ADJUSTMENT = 15;

/**
 * D3 code for setting up scatter plot chart area
 *
 * @param {object}   attributes
 */
function create_scatter_plot(attributes) {
    let dataset = attributes["dataset"];
    let properties = attributes["graph_attributes"];
    var window_height_multiplier = 0.65
    var window_width_multiplier = 0.5

    // numerosity experiments require a larger window size due to the nature of the task
    if (properties.alternate_scaling) {
      BUFFER = 150;
      window_height_multiplier = 1.15;
      window_width_multiplier = .75;
    }

    // Size of the graph
    let height = window.innerHeight * window_height_multiplier;
    let width = height * window_width_multiplier;

    // Create scales:
    // ** D3 creates a function that takes in input between [0, 100] and 
    //    outputs between [0, width].
    //    Basically, domain = input, range = ouput. 
    let xscale = d3.scaleLinear()
                   .range([0, width - RANGE_ADJUSTMENT]); 

    let yscale = d3.scaleLinear()
                   .range([height/2, 0 + RANGE_ADJUSTMENT]);
    if (properties.alternate_scaling) {
      // Numerosity experiments do not scale the distribution
      // coordinates down to the range of [0,1] so the
      // call to .domain() in the scaleLinear() method is needed
      // scale the domain down into the appropriate range
      var x_max = properties.row;
      var y_max = properties.col;

      xscale = d3.scaleLinear()
                   .domain([0, x_max]) 
                   .range([0, width - RANGE_ADJUSTMENT]); 
                   
      yscale = d3.scaleLinear()
                   .domain([0, y_max])
                   .range([height/2, 0 + RANGE_ADJUSTMENT]);
    }
    // Create axes: 
    let x_axis = d3.axisBottom()
                   .scale(xscale)
                   .tickSize([0]);

    let y_axis = d3.axisLeft()
                   .scale(yscale)
                   .tickSize([0]);

    // Append SVG into graph div
    let chart = d3.select("#graph")
                  .append("svg") 
                    .attr("width", width + BUFFER)
                    .attr("height", height)
                    .attr("style", `margin-right: ${width/2}; margin-top: 25vh; margin-left: ${BUFFER}`);
                    
    // Creating transform SVG elements + append to SVG: 
    let yAxisElements = chart.append("g")
                             .attr("transform", "translate(50, 10)")
                             .call(y_axis);

    let xAxisTranslate = height/2 + 10;

    let xAxisElements = chart.append("g")
                              .attr("transform", "translate(50, " + xAxisTranslate  +")")
                              .call(x_axis)

    // Check if is a mix subcondition
    if (!isEmpty(properties["mix_by_attribute"])){
      prepare_mix_graph(chart, xscale, yscale, dataset, properties);
    } else if (!isEmpty(properties["mix_by_attribute_targeted"])) {
      prepare_targeted_mix_graph(chart, xscale, yscale, dataset, properties);
    } else {
      plot_scatter_points(chart, xscale, yscale, dataset, properties);    
    }
        
    // Set axis color
    chart.selectAll("path")
         .attr("stroke", properties["axis_color"]);

    // Remove tick labels
    chart.selectAll("text").remove();     

}

/**
 * Will plot points depending on what is defined as the mix attribute,
 * and will sample (w/o replacement) from the dataset to display points
 * with all defined values for the mix attribute.
 *
 * E.g. if properties["mix_by_attribute"] = {"point_size" = [1, 2, 3, 4]}
 *      and there are 100 points in the dataset, will plot 25 points with
 *      point_size = 1, 25 points with point_size = 2, etc...
 *
 * @param {object}         chart
 * @param {function}       xscale
 * @param {function}       yscale
 * @param {array}          dataset ([x_value, y_value])
 * @param {assoc. array}   e.g. {"point_shape": "square", "point_size": 5 .... }
 */
function prepare_mix_graph(chart, xscale, yscale, dataset, properties) {

  let mix_attrib = Object.keys(properties["mix_by_attribute"])[0];
  let NUM_GROUPS = properties["mix_by_attribute"][mix_attrib].length;
  let POINTS_PER_GROUP = dataset.length / NUM_GROUPS;

  for (let i = 0; i < NUM_GROUPS; i++) {

    let grouped_dataset = [];

    for (let j = 0; j < POINTS_PER_GROUP; j++) {
      let rand_index = Math.floor(Math.random() * dataset.length);
      grouped_dataset.push(dataset[rand_index]);

      // Remove from dataset
      dataset.splice(rand_index, 1);
    }

    // Override the property if it is a mix attribute
    properties[mix_attrib] = properties["mix_by_attribute"][mix_attrib][i];

    // Plot this group of points with the ith value for the mix attribute
    plot_scatter_points(chart, xscale, yscale, grouped_dataset, properties);
  }

}

/**
 * The same as prepare_mix_graph but now the first item in the mix by attributes array
 * is treated as the target
 * The user can specify how many target points there are
 * The rest of the mix by attributes are treated as distractors
 * and the remaining number of points is distributed evenly among them
 * 
 * e.g. if if properties["mix_by_attribute_targeted"] = {"point_size" = [1, 2, 3, 4], num_target: 10}
 *      and there are 100 points in the dataset, will plot 10 points with
 *      point_size = 1, 30 points with point_size = 2, 3 and 4.
 *
 * @param {object}         chart
 * @param {function}       xscale
 * @param {function}       yscale
 * @param {array}          dataset ([x_value, y_value])
 * @param {assoc. array}   e.g. {"point_shape": "square", "point_size": 5 .... }
 */
function prepare_targeted_mix_graph(chart, xscale, yscale, dataset, properties) {

  let mix_attrib = Object.keys(properties["mix_by_attribute_targeted"])[0];
  let POINTS_FOR_TARGET = properties["mix_by_attribute_targeted"]["num_target"];
  
  let NUM_GROUPS = properties["mix_by_attribute_targeted"][mix_attrib].length;
  if (POINTS_FOR_TARGET < 0) {
    NUM_GROUPS = NUM_GROUPS - 1;
  }
  let POINTS_PER_GROUP = (dataset.length - POINTS_FOR_TARGET) / NUM_GROUPS;

  for (let i = 0; i < NUM_GROUPS; i++) {

    let grouped_dataset = [];
    if (i == 0 && POINTS_FOR_TARGET == 0) {
      continue;
    }
  
    if (i == 0) {
      for (let h = 0; h < POINTS_FOR_TARGET; h++) {
        let rand_index = Math.floor(Math.random() * dataset.length);
        grouped_dataset.push(dataset[rand_index]);
        // Remove from dataset
        dataset.splice(rand_index, 1);
      }
    } else {
        for (let j = 0; j < POINTS_PER_GROUP; j++) {
          let rand_index = Math.floor(Math.random() * dataset.length);
          grouped_dataset.push(dataset[rand_index]);

          // Remove from dataset
          dataset.splice(rand_index, 1);
        }
    }

    // Override the property if it is a mix attribute
    properties[mix_attrib] = properties["mix_by_attribute_targeted"][mix_attrib][i];
    // Plot this group of points with the ith value for the mix attribute
    plot_scatter_points(chart, xscale, yscale, grouped_dataset, properties);
  }

}

/**
 * D3 code for appending data to the graph depending on point shape type.
 *
 * @param {object}         chart
 * @param {function}       xscale
 * @param {function}       yscale
 * @param {array}          data ([x_value, y_value])
 * @param {assoc. array}   properties e.g. {"point_shape": "square", "point_size": 5 .... }
 */
function plot_scatter_points(chart, xscale, yscale, data, properties) {
  // var GRAPH_TYPES comes from /config/graphing-config.js
  if (!GRAPH_TYPES["scatter"]["attributes"]["point_shape"]["valid_inputs"].includes(properties["point_shape"])){
    throw Error("Point shape " + properties["point_shape"] + " is not a valid shape for graph type scatter.");
  }

  switch(properties["point_shape"]){
    case "square":
      chart.selectAll("square_data")
                 .data(data)
                  .enter()
                  .append("rect") 
                  .attr("x", function (d){
                    return xscale(d[0]) + BUFFER;
                  })
                  .attr("y", function (d){
                    return yscale(d[1]);
                  })
                  .attr("width", properties["point_size"])
                  .attr("height", properties["point_size"])
                  .style('fill', properties["point_color"]);
      break;

    case "diamond":
      chart.selectAll("square_data")
                 .data(data)
                  .enter()
                  .append("rect") 
                  .attr("x", function (d){
                    return xscale(d[0]) + BUFFER;
                  })
                  .attr("y", function (d){
                    return yscale(d[1]);
                  })
                  .attr("width", properties["point_size"])
                  .attr("height", properties["point_size"])
                  .style('fill', properties["point_color"])
                  .attr('transform', function(d){
                    // Adapted from: https://stackoverflow.com/questions/44817414/rotate-svg-in-place-using-d3-js
                    var x1 = xscale(d[0]) + BUFFER + properties["point_size"]/2; //the center x about which you want to rotate
                    var y1 = yscale(d[1]) + properties["point_size"]/2; //the center y about which you want to rotate

                    return `rotate(45, ${x1}, ${y1})`; //rotate 180 degrees about x and y
                }); 
      break;

    case "circle":
      chart.selectAll("circle_data")
                 .data(data)
                  .enter()
                  .append("circle") // Creating the circles for each entry in data set 
                  .attr("cx", function (d) { // d is a subarray of the dataset i.e coordinates [5, 20]
                    return xscale(d[0]) + BUFFER; // +60 is for buffer (points going -x, even if they are positive)
                  })
                  .attr("cy", function (d) {
                    return yscale(d[1]);
                  })
                  .attr("r", function () {
                    if (properties["convert_size_cm_to_pixel"]){
                      return (properties["point_size"] * properties["pixels_per_cm"]) / 2
                    }
                    return properties["point_size"]/2;
                  })
                  .attr("fill", properties["point_color"]);
      break;
	
  	case "hollow_circle":
      chart.selectAll("circle_data")
                 .data(data)
                  .enter()
                  .append("circle") // Creating the circles for each entry in data set 
                  .attr("cx", function (d) { // d is a subarray of the dataset i.e coordinates [5, 20]
                    return xscale(d[0]) + BUFFER; // +60 is for buffer (points going -x, even if they are positive)
                  })
                  .attr("cy", function (d) {
                    return yscale(d[1]);
                  })
                  .attr("r", properties["point_size"]/2).style("fill", "none").style("stroke", properties["point_color"]).style("stroke-width", "3");
      break;
	
	  case "bullseye_circle":
      chart.selectAll("circle_data")
                 .data(data)
                  .enter()
                  .append("circle") // Creating the circles for each entry in data set 
                  .attr("cx", function (d) { // d is a subarray of the dataset i.e coordinates [5, 20]
                    return xscale(d[0]) + BUFFER; // +60 is for buffer (points going -x, even if they are positive)
                  })
                  .attr("cy", function (d) {
                    return yscale(d[1]);
                  })
                  .attr("r", properties["point_size"]/4).style("fill", properties["point_color"]);
				  
      chart.selectAll("circle_data")
                 .data(data)
                  .enter()
                  .append("circle") // Creating the circles for each entry in data set 
                  .attr("cx", function (d) { // d is a subarray of the dataset i.e coordinates [5, 20]
                    return xscale(d[0]) + BUFFER; // +60 is for buffer (points going -x, even if they are positive)
                  })
                  .attr("cy", function (d) {
                    return yscale(d[1]);
                  })
                  .attr("r", properties["point_size"]/2).style("fill", "none").style("stroke", properties["point_color"]).style("stroke-width", "1");
      break;
	  
  	case "thin_hollow_circle":
      chart.selectAll("circle_data")
                 .data(data)
                  .enter()
                  .append("circle") // Creating the circles for each entry in data set 
                  .attr("cx", function (d) { // d is a subarray of the dataset i.e coordinates [5, 20]
                    return xscale(d[0]) + BUFFER; // +60 is for buffer (points going -x, even if they are positive)
                  })
                  .attr("cy", function (d) {
                    return yscale(d[1]);
                  })
                  .attr("r", properties["point_size"]/2).style("fill", "none").style("stroke", properties["point_color"]).style("stroke-width", "1");
      break;
  }
}

// https://coderwall.com/p/_g3x9q/how-to-check-if-javascript-object-is-empty
function isEmpty(obj) {
  for(var key in obj) {
      if(obj.hasOwnProperty(key))
          return false;
  }
  return true;
}