function TimelineNode(parameters, parent, relativeID) {

    // a unique ID for this node, relative to the parent
    var relative_id;

    // store the parent for this node
    var parent_node;

    // parameters for the trial if the node contains a trial
    var trial_parameters;

    // parameters for nodes that contain timelines
    var timeline_parameters;

    // stores trial information on a node that contains a timeline
    // used for adding new trials
    var node_trial_data;

    // track progress through the node
    var progress = {
      current_location: -1, // where on the timeline (which timelinenode)
      current_variable_set: 0, // which set of variables to use from timeline_variables
      current_repetition: 0, // how many times through the variable set on this run of the node
      current_iteration: 0, // how many times this node has been revisited
      done: false
    }

    // reference to self
    var self = this;

    // recursively get the next trial to run.
    // if this node is a leaf (trial), then return the trial.
    // otherwise, recursively find the next trial in the child timeline.
    this.trial = function() {
      if (typeof timeline_parameters == 'undefined') {
        // returns a clone of the trial_parameters to
        // protect functions.
        return jsPsych.utils.deepCopy(trial_parameters);
      } else {
        if (progress.current_location >= timeline_parameters.timeline.length) {
          return null;
        } else {
          return timeline_parameters.timeline[progress.current_location].trial();
        }
      }
    }

    this.markCurrentTrialComplete = function() {
      if(typeof timeline_parameters == 'undefined'){
        progress.done = true;
      } else {
        timeline_parameters.timeline[progress.current_location].markCurrentTrialComplete();
      }
    }

    this.nextRepetiton = function() {
      this.setTimelineVariablesOrder();
      progress.current_location = -1;
      progress.current_variable_set = 0;
      progress.current_repetition++;
      for (var i = 0; i < timeline_parameters.timeline.length; i++) {
        timeline_parameters.timeline[i].reset();
      }
    }

    // set the order for going through the timeline variables array
    this.setTimelineVariablesOrder = function() {

      // check to make sure this node has variables
      if(typeof timeline_parameters === 'undefined' || typeof timeline_parameters.timeline_variables === 'undefined'){
        return;
      }

      var order = [];
      for(var i=0; i<timeline_parameters.timeline_variables.length; i++){
        order.push(i);
      }

      if(typeof timeline_parameters.sample !== 'undefined'){
        if(timeline_parameters.sample.type == 'custom'){
          order = timeline_parameters.sample.fn(order);
        } else if(timeline_parameters.sample.type == 'with-replacement'){
          order = jsPsych.randomization.sampleWithReplacement(order, timeline_parameters.sample.size, timeline_parameters.sample.weights);
        } else if(timeline_parameters.sample.type == 'without-replacement'){
          order = jsPsych.randomization.sampleWithoutReplacement(order, timeline_parameters.sample.size);
        } else if(timeline_parameters.sample.type == 'fixed-repetitions'){
          order = jsPsych.randomization.repeat(order, timeline_parameters.sample.size, false);
        } else if(timeline_parameters.sample.type == 'alternate-groups'){
          order = jsPsych.randomization.shuffleAlternateGroups(timeline_parameters.sample.groups, timeline_parameters.sample.randomize_group_order);
        } else {
          console.error('Invalid type in timeline sample parameters. Valid options for type are "custom", "with-replacement", "without-replacement", "fixed-repetitions", and "alternate-groups"');
        }
      }

      if(timeline_parameters.randomize_order) {
        order = jsPsych.randomization.shuffle(order);
      }

      progress.order = order;
    }

    // next variable set
    this.nextSet = function() {
      progress.current_location = -1;
      progress.current_variable_set++;
      for (var i = 0; i < timeline_parameters.timeline.length; i++) {
        timeline_parameters.timeline[i].reset();
      }
    }

    // update the current trial node to be completed
    // returns true if the node is complete after advance (all subnodes are also complete)
    // returns false otherwise
    this.advance = function() {

      // first check to see if done
      if (progress.done) {
        return true;
      }

      // if node has not started yet (progress.current_location == -1),
      // then try to start the node.
      if (progress.current_location == -1) {
        // check for conditonal function on nodes with timelines
        if (typeof timeline_parameters != 'undefined') {
          if (typeof timeline_parameters.conditional_function !== 'undefined') {
            var conditional_result = timeline_parameters.conditional_function();
            // if the conditional_function() returns false, then the timeline
            // doesn't run and is marked as complete.
            if (conditional_result == false) {
              progress.done = true;
              return true;
            }
            // if the conditonal_function() returns true, then the node can start
            else {
              progress.current_location = 0;
            }
          }
          // if there is no conditional_function, then the node can start
          else {
            progress.current_location = 0;
          }
        }
        // if the node does not have a timeline, then it can start
        progress.current_location = 0;
        // call advance again on this node now that it is pointing to a new location
        return this.advance();
      }

      // if this node has a timeline, propogate down to the current trial.
      if (typeof timeline_parameters !== 'undefined') {

        var have_node_to_run = false;
        // keep incrementing the location in the timeline until one of the nodes reached is incomplete
        while (progress.current_location < timeline_parameters.timeline.length && have_node_to_run == false) {

          // check to see if the node currently pointed at is done
          var target_complete = timeline_parameters.timeline[progress.current_location].advance();
          if (!target_complete) {
            have_node_to_run = true;
            return false;
          } else {
            progress.current_location++;
          }

        }

        // if we've reached the end of the timeline (which, if the code is here, we have)
        // there are a few steps to see what to do next...

        // first, check the timeline_variables to see if we need to loop through again
        // with a new set of variables
        if (progress.current_variable_set < progress.order.length - 1) {
          // reset the progress of the node to be with the new set
          this.nextSet();
          // then try to advance this node again.
          return this.advance();
        }

        // if we're all done with the timeline_variables, then check to see if there are more repetitions
        else if (progress.current_repetition < timeline_parameters.repetitions - 1) {
          this.nextRepetiton();
          return this.advance();
        }

        // if we're all done with the repetitions, check if there is a loop function.
        else if (typeof timeline_parameters.loop_function !== 'undefined') {
          if (timeline_parameters.loop_function(this.generatedData())) {
            this.reset();
            return parent_node.advance();
          } else {
            progress.done = true;
            return true;
          }
        }

        // no more loops on this timeline, we're done!
        else {
          progress.done = true;
          return true;
        }

      }
    }

    // check the status of the done flag
    this.isComplete = function() {
      return progress.done;
    }

    // getter method for timeline variables
    this.getTimelineVariableValue = function(variable_name){
      if(typeof timeline_parameters == 'undefined'){
        return undefined;
      }
      var v = timeline_parameters.timeline_variables[progress.order[progress.current_variable_set]][variable_name];
      return v;
    }

    // recursive upward search for timeline variables
    this.findTimelineVariable = function(variable_name){
      var v = this.getTimelineVariableValue(variable_name);
      if(typeof v == 'undefined'){
        if(typeof parent_node !== 'undefined'){
          return parent_node.findTimelineVariable(variable_name);
        } else {
          return undefined;
        }
      } else {
        return v;
      }
    }

    // recursive downward search for active trial to extract timeline variable
    this.timelineVariable = function(variable_name){
      if(typeof timeline_parameters == 'undefined'){
        return this.findTimelineVariable(variable_name);
      } else {
        // if progress.current_location is -1, then the timeline variable is being evaluated
        // in a function that runs prior to the trial starting, so we should treat that trial
        // as being the active trial for purposes of finding the value of the timeline variable
        var loc = Math.max(0, progress.current_location); 
        return timeline_parameters.timeline[loc].timelineVariable(variable_name);
      }
    }

    // recursively get the number of **trials** contained in the timeline
    // assuming that while loops execute exactly once and if conditionals
    // always run
    this.length = function() {
      var length = 0;
      if (typeof timeline_parameters !== 'undefined') {
        for (var i = 0; i < timeline_parameters.timeline.length; i++) {
          length += timeline_parameters.timeline[i].length();
        }
      } else {
        return 1;
      }
      return length;
    }

    // return the percentage of trials completed, grouped at the first child level
    // counts a set of trials as complete when the child node is done
    this.percentComplete = function() {
      var total_trials = this.length();
      var completed_trials = 0;
      for (var i = 0; i < timeline_parameters.timeline.length; i++) {
        if (timeline_parameters.timeline[i].isComplete()) {
          completed_trials += timeline_parameters.timeline[i].length();
        }
      }
      return (completed_trials / total_trials * 100)
    }

    // resets the node and all subnodes to original state
    // but increments the current_iteration counter
    this.reset = function() {
      progress.current_location = -1;
      progress.current_repetition = 0;
      progress.current_variable_set = 0;
      progress.current_iteration++;
      progress.done = false;
      this.setTimelineVariablesOrder();
      if (typeof timeline_parameters != 'undefined') {
        for (var i = 0; i < timeline_parameters.timeline.length; i++) {
          timeline_parameters.timeline[i].reset();
        }
      }

    }

    // mark this node as finished
    this.end = function() {
      progress.done = true;
    }

    // recursively end whatever sub-node is running the current trial
    this.endActiveNode = function() {
      if (typeof timeline_parameters == 'undefined') {
        this.end();
        parent_node.end();
      } else {
        timeline_parameters.timeline[progress.current_location].endActiveNode();
      }
    }

    // get a unique ID associated with this node
    // the ID reflects the current iteration through this node.
    this.ID = function() {
      var id = "";
      if (typeof parent_node == 'undefined') {
        return "0." + progress.current_iteration;
      } else {
        id += parent_node.ID() + "-";
        id += relative_id + "." + progress.current_iteration;
        return id;
      }
    }

    // get the ID of the active trial
    this.activeID = function() {
      if (typeof timeline_parameters == 'undefined') {
        return this.ID();
      } else {
        return timeline_parameters.timeline[progress.current_location].activeID();
      }
    }

    // get all the data generated within this node
    this.generatedData = function() {
      return jsPsych.data.getDataByTimelineNode(this.ID());
    }

    // get all the trials of a particular type
    this.trialsOfType = function(type) {
      if (typeof timeline_parameters == 'undefined'){
        if (trial_parameters.type == type) {
          return trial_parameters;
        } else {
          return [];
        }
      } else {
        var trials = [];
        for (var i = 0; i < timeline_parameters.timeline.length; i++) {
          var t = timeline_parameters.timeline[i].trialsOfType(type);
          trials = trials.concat(t);
        }
        return trials;
      }
    }

    // add new trials to end of this timeline
    this.insert = function(parameters){
      if(typeof timeline_parameters == 'undefined'){
        console.error('Cannot add new trials to a trial-level node.');
      } else {
        timeline_parameters.timeline.push(
          new TimelineNode(Object.assign({}, node_trial_data, parameters), self, timeline_parameters.timeline.length)
        );
      }
    }

    // constructor
    var _construct = function() {

      // store a link to the parent of this node
      parent_node = parent;

      // create the ID for this node
      if (typeof parent == 'undefined') {
        relative_id = 0;
      } else {
        relative_id = relativeID;
      }

      // check if there is a timeline parameter
      // if there is, then this node has its own timeline
      if ((typeof parameters.timeline !== 'undefined') || (typeof jsPsych.plugins[trial_type] == 'function')) {

        // create timeline properties
        timeline_parameters = {
          timeline: [],
          loop_function: parameters.loop_function,
          conditional_function: parameters.conditional_function,
          sample: parameters.sample,
          randomize_order: typeof parameters.randomize_order == 'undefined' ? false : parameters.randomize_order,
          repetitions: typeof parameters.repetitions == 'undefined' ? 1 : parameters.repetitions,
          timeline_variables: typeof parameters.timeline_variables == 'undefined' ? [{}] : parameters.timeline_variables
        };

        self.setTimelineVariablesOrder();

        // extract all of the node level data and parameters
        var node_data = Object.assign({}, parameters);
        delete node_data.timeline;
        delete node_data.conditional_function;
        delete node_data.loop_function;
        delete node_data.randomize_order;
        delete node_data.repetitions;
        delete node_data.timeline_variables;
        delete node_data.sample;
        node_trial_data = node_data; // store for later...

        // create a TimelineNode for each element in the timeline
        for (var i = 0; i < parameters.timeline.length; i++) {
          // merge parameters
          var merged_parameters = Object.assign({}, node_data, parameters.timeline[i]);
          // merge any data from the parent node into child nodes
          if(typeof node_data.data == 'object' && typeof parameters.timeline[i].data == 'object'){
            var merged_data = Object.assign({}, node_data.data, parameters.timeline[i].data);
            merged_parameters.data = merged_data;
          }
          timeline_parameters.timeline.push(new TimelineNode(merged_parameters, self, i));
        }
      }
      // if there is no timeline parameter, then this node is a trial node
      else {
        // check to see if a valid trial type is defined
        var trial_type = parameters.type;
        if (typeof trial_type == 'undefined') {
          console.error('Trial level node is missing the "type" parameter. The parameters for the node are: ' + JSON.stringify(parameters));
        } else if ((typeof jsPsych.plugins[trial_type] == 'undefined') && (trial_type.toString().replace(/\s/g,'') != "function(){returntimeline.timelineVariable(varname);}")) {
          console.error('No plugin loaded for trials of type "' + trial_type + '"');
        }
        // create a deep copy of the parameters for the trial
        trial_parameters = Object.assign({}, parameters);
      }

    }();
  }
jspsych.js
Josh de Leeuw

documentation: docs.jspsych.org