From 4e88af18c171561898a3a4c7504022f6172af544 Mon Sep 17 00:00:00 2001 From: bjoluc Date: Sun, 3 Nov 2024 19:13:24 +0100 Subject: [PATCH 01/15] Respect dynamically added trial/timeline descriptions --- .../jspsych/src/timeline/Timeline.spec.ts | 24 +++++++++++++++---- packages/jspsych/src/timeline/Timeline.ts | 21 +++++++++------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index b87e4f4929..eb5ca3e74b 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -58,6 +58,23 @@ describe("Timeline", () => { expect((children[1] as Timeline).children.map((child) => child.index)).toEqual([1, 2]); }); + it("respects dynamically added child node descriptions", async () => { + TestPlugin.setManualFinishTrialMode(); + + const timelineDescription: TimelineArray = [{ type: TestPlugin }]; + const timeline = createTimeline(timelineDescription); + + const runPromise = timeline.run(); + expect(timeline.children.length).toEqual(1); + + timelineDescription.push({ timeline: [{ type: TestPlugin }] }); + await TestPlugin.finishTrial(); + await TestPlugin.finishTrial(); + await runPromise; + + expect(timeline.children.length).toEqual(2); + }); + describe("with `pause()` and `resume()` calls`", () => { beforeEach(() => { TestPlugin.setManualFinishTrialMode(); @@ -84,12 +101,9 @@ describe("Timeline", () => { await TestPlugin.finishTrial(); expect(timeline.children[1].getStatus()).toBe(TimelineNodeStatus.COMPLETED); - expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.PENDING); - - // Resolving the next trial promise shouldn't continue the experiment since no trial should be running. - await TestPlugin.finishTrial(); - expect(timeline.children[2].getStatus()).toBe(TimelineNodeStatus.PENDING); + // The timeline is paused, so it shouldn't have instantiated the next child node yet. + expect(timeline.children.length).toEqual(2); timeline.resume(); await flushPromises(); diff --git a/packages/jspsych/src/timeline/Timeline.ts b/packages/jspsych/src/timeline/Timeline.ts index 8628f60324..eb765f8dd2 100644 --- a/packages/jspsych/src/timeline/Timeline.ts +++ b/packages/jspsych/src/timeline/Timeline.ts @@ -75,7 +75,9 @@ export class Timeline extends TimelineNode { for (const timelineVariableIndex of timelineVariableOrder) { this.setCurrentTimelineVariablesByIndex(timelineVariableIndex); - for (const childNode of this.instantiateChildNodes()) { + for (const childNodeDescription of this.description.timeline) { + const childNode = this.instantiateChildNode(childNodeDescription); + const previousChild = this.currentChild; this.currentChild = childNode; childNode.index = previousChild @@ -151,14 +153,15 @@ export class Timeline extends TimelineNode { } } - private instantiateChildNodes() { - const newChildNodes = this.description.timeline.map((childDescription) => { - return isTimelineDescription(childDescription) - ? new Timeline(this.dependencies, childDescription, this) - : new Trial(this.dependencies, childDescription, this); - }); - this.children.push(...newChildNodes); - return newChildNodes; + private instantiateChildNode( + childDescription: TimelineDescription | TimelineArray | TrialDescription + ) { + const newChildNode = isTimelineDescription(childDescription) + ? new Timeline(this.dependencies, childDescription, this) + : new Trial(this.dependencies, childDescription, this); + + this.children.push(newChildNode); + return newChildNode; } private currentTimelineVariables: Record; From 9b55e9dfcc43bf3d9740822633366ec555c40c0b Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Mon, 4 Nov 2024 17:42:18 -0500 Subject: [PATCH 02/15] add tests for dynamically removing timeline nodes --- .../jspsych/src/timeline/Timeline.spec.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index eb5ca3e74b..cb073e84de 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -75,6 +75,72 @@ describe("Timeline", () => { expect(timeline.children.length).toEqual(2); }); + it("respects dynamically removed end child node descriptions", async () => { + TestPlugin.setManualFinishTrialMode(); + + const timelineDescription: TimelineArray = [ + { type: TestPlugin }, + { type: TestPlugin }, + { timeline: [{ type: TestPlugin }] }, + ]; + const timeline = createTimeline(timelineDescription); + + const runPromise = timeline.run(); + expect(timeline.children.length).toEqual(1); // Only the first child is instantiated because they are incrementally instantiated now + + timelineDescription.pop(); + await TestPlugin.finishTrial(); + await TestPlugin.finishTrial(); + await runPromise; + + expect(timeline.children.length).toEqual(2); + expect(timeline.children).toEqual([expect.any(Trial), expect.any(Trial)]); + }); + + it("respects dynamically removed first child node descriptions", async () => { + TestPlugin.setManualFinishTrialMode(); + + const timelineDescription: TimelineArray = [ + { type: TestPlugin }, + { timeline: [{ type: TestPlugin }] }, + { type: TestPlugin }, + ]; + const timeline = createTimeline(timelineDescription); + + const runPromise = timeline.run(); + expect(timeline.children.length).toEqual(1); + + timelineDescription.shift(); + await TestPlugin.finishTrial(); + await TestPlugin.finishTrial(); + await runPromise; + + expect(timeline.children.length).toEqual(2); + expect(timeline.children).toEqual([expect.any(Timeline), expect.any(Trial)]); + }); + + it("respects dynamically removed middle child node descriptions", async () => { + TestPlugin.setManualFinishTrialMode(); + + const timelineDescription: TimelineArray = [ + { type: TestPlugin }, + { timeline: [{ type: TestPlugin }] }, + { type: TestPlugin }, + ]; + const timeline = createTimeline(timelineDescription); + + const runPromise = timeline.run(); + expect(timeline.children.length).toEqual(1); + + timelineDescription.splice(1, 1); + await TestPlugin.finishTrial(); + await TestPlugin.finishTrial(); + await runPromise; + + expect(timeline.children.length).toEqual(2); + expect(timeline.children).toEqual([expect.any(Trial), expect.any(Trial)]); + }); + describe("with `pause()` and `resume()` calls`", () => { beforeEach(() => { TestPlugin.setManualFinishTrialMode(); From 214fb77247f6c2ed6d8b0a245acd95ed1bc181fc Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Mon, 4 Nov 2024 17:44:17 -0500 Subject: [PATCH 03/15] change end node removal test --- packages/jspsych/src/timeline/Timeline.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index cb073e84de..6b24c19732 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -79,9 +79,9 @@ describe("Timeline", () => { TestPlugin.setManualFinishTrialMode(); const timelineDescription: TimelineArray = [ - { type: TestPlugin }, { type: TestPlugin }, { timeline: [{ type: TestPlugin }] }, + { type: TestPlugin }, ]; const timeline = createTimeline(timelineDescription); @@ -94,7 +94,7 @@ describe("Timeline", () => { await runPromise; expect(timeline.children.length).toEqual(2); - expect(timeline.children).toEqual([expect.any(Trial), expect.any(Trial)]); + expect(timeline.children).toEqual([expect.any(Trial), expect.any(Timeline)]); }); it("respects dynamically removed first child node descriptions", async () => { From e599088b47651dc890d260e38c7004845528f3dd Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Tue, 5 Nov 2024 09:21:58 -0500 Subject: [PATCH 04/15] delete remove first node test --- .../jspsych/src/timeline/Timeline.spec.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index 6b24c19732..87f9b51bc2 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -97,28 +97,6 @@ describe("Timeline", () => { expect(timeline.children).toEqual([expect.any(Trial), expect.any(Timeline)]); }); - it("respects dynamically removed first child node descriptions", async () => { - TestPlugin.setManualFinishTrialMode(); - - const timelineDescription: TimelineArray = [ - { type: TestPlugin }, - { timeline: [{ type: TestPlugin }] }, - { type: TestPlugin }, - ]; - const timeline = createTimeline(timelineDescription); - - const runPromise = timeline.run(); - expect(timeline.children.length).toEqual(1); - - timelineDescription.shift(); - await TestPlugin.finishTrial(); - await TestPlugin.finishTrial(); - await runPromise; - - expect(timeline.children.length).toEqual(2); - expect(timeline.children).toEqual([expect.any(Timeline), expect.any(Trial)]); - }); - it("respects dynamically removed middle child node descriptions", async () => { TestPlugin.setManualFinishTrialMode(); From 5a2265ec36589cd5363545c2a34d7535f51f4931 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Tue, 5 Nov 2024 11:54:35 -0500 Subject: [PATCH 05/15] add test for checking how for of interacts with node removal --- .../jspsych/src/timeline/Timeline.spec.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index 87f9b51bc2..398e8574ff 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -119,6 +119,30 @@ describe("Timeline", () => { expect(timeline.children).toEqual([expect.any(Trial), expect.any(Trial)]); }); + it("dynamically remove first node after running it", async () => { + TestPlugin.setManualFinishTrialMode(); + + const timelineDescription: TimelineArray = [ + { type: TestPlugin, data: { I: 0 } }, + { timeline: [{ type: TestPlugin, data: { I: 1 } }] }, + { type: TestPlugin, data: { I: 2 } }, + { type: TestPlugin, data: { I: 3 } }, + ]; + const timeline = createTimeline(timelineDescription); + + const runPromise = timeline.run(); + await TestPlugin.finishTrial(); + timelineDescription.shift(); + await TestPlugin.finishTrial(); + await TestPlugin.finishTrial(); + await runPromise; + + expect(timeline.children.length).toEqual(3); + expect(timeline.children[0].getDataParameter().I).toEqual(0); + expect(timeline.children[1].description.timeline[0]).toHaveProperty("data.I", 1); + expect(timeline.children[2].getDataParameter().I).toEqual(3); + }); + describe("with `pause()` and `resume()` calls`", () => { beforeEach(() => { TestPlugin.setManualFinishTrialMode(); From bfeeea3ab217f9d7f2bcb1e8f2dcc3f0e59761a7 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Tue, 5 Nov 2024 12:04:08 -0500 Subject: [PATCH 06/15] add changeset for dynamic timeline node insert and removal --- .changeset/large-plums-guess.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/large-plums-guess.md diff --git a/.changeset/large-plums-guess.md b/.changeset/large-plums-guess.md new file mode 100644 index 0000000000..fd7bbc0282 --- /dev/null +++ b/.changeset/large-plums-guess.md @@ -0,0 +1,5 @@ +--- +"jspsych": minor +--- + +We added a feature that allows users to dynamically add or remove trials or nested timelines to a timeline array during runtime. From 1735c4e28dfe8cb161e612fadb953d1153529117 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Tue, 5 Nov 2024 12:10:40 -0500 Subject: [PATCH 07/15] fix "dynamically remove first node after running it" test warning --- packages/jspsych/src/timeline/Timeline.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index 398e8574ff..1a660fd7ba 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -139,7 +139,8 @@ describe("Timeline", () => { expect(timeline.children.length).toEqual(3); expect(timeline.children[0].getDataParameter().I).toEqual(0); - expect(timeline.children[1].description.timeline[0]).toHaveProperty("data.I", 1); + const secondChildDescription = timeline.children[1].description as TimelineDescription; + expect(secondChildDescription["timeline"][0]).toHaveProperty("data.I", 1); expect(timeline.children[2].getDataParameter().I).toEqual(3); }); From d274f5e02ee0b1d1d6a4ae220d3b63a9e0763bf3 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Tue, 5 Nov 2024 15:36:26 -0500 Subject: [PATCH 08/15] add docs for dynamic timeline descriptions; add test for adding first node before original first node is done executing --- docs/overview/timeline.md | 105 ++++++++++++++++++ .../jspsych/src/timeline/Timeline.spec.ts | 34 ++++++ 2 files changed, 139 insertions(+) diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index 714ab96458..b8fa422967 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -1,3 +1,24 @@ +- [Creating an Experiment: The Timeline](#creating-an-experiment-the-timeline) + - [A single trial](#a-single-trial) + - [Multiple trials](#multiple-trials) + - [Nested timelines](#nested-timelines) + - [Timeline variables](#timeline-variables) + - [Using timeline variables in a function](#using-timeline-variables-in-a-function) + - [Random orders of trials](#random-orders-of-trials) + - [Sampling methods](#sampling-methods) + - [Sampling with replacement](#sampling-with-replacement) + - [Sampling with replacement, unequal probabilities](#sampling-with-replacement-unequal-probabilities) + - [Sampling without replacement](#sampling-without-replacement) + - [Repeating each trial a fixed number of times in a random order](#repeating-each-trial-a-fixed-number-of-times-in-a-random-order) + - [Alternating groups](#alternating-groups) + - [Custom sampling function](#custom-sampling-function) + - [Repeating a set of trials](#repeating-a-set-of-trials) + - [Looping timelines](#looping-timelines) + - [Conditional timelines](#conditional-timelines) + - [Modifying timelines at runtime](#modifying-timelines-at-runtime) + - [Exception cases for adding/removing timeline nodes dynamically](#exception-cases-for-addingremoving-timeline-nodes-dynamically) + - [Timeline start and finish functions](#timeline-start-and-finish-functions) + # Creating an Experiment: The Timeline To create an experiment using jsPsych, you need to specify a timeline that describes the structure of the experiment. The timeline is an ordered set of trials. You must create the timeline before launching the experiment. Most of the code you will write for an experiment will be code to create the timeline. This page walks through the creation of timelines, including very basic examples and more advanced features. @@ -467,6 +488,90 @@ const after_if_trial = { jsPsych.run([pre_if_trial, if_node, after_if_trial]); ``` +## Modifying timelines at runtime + +Although this functionality can also be achieved through a combination of the `conditional_function` and the use of dynamic variables in the `stimulus` parameter, our timeline implementation allows you to dynamically insert or remove trials and nested timelines during runtime. For example, you may have a branching point in your experiment where the participant is given 4 choices, each leading to a different timeline: + +```javascript +const jspsych = initJsPsych(); +let timeline = []; + +const welcome_trial = { + type: jsPsychHtmlKeyboardResponse, + stimulus: 'Welcome!' +} + +const choice_trial = { + type: jsPsychHtmlKeyboardResponse, + stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish. Press 4 to see the welcome page and these options again (in case of stupidity-induced blindness). Press 5 to exit.' +} + +const start_node = { + timeline: [welcome_trial, choice_trial], + loop_function: function(data){ + if(jsPsych.pluginAPI.compareKeys(data.values()[0].response, '4')){ + return true; + } else { + return false; + } + } +} + +timeline.push(start_node); +``` +This would be trickier to implement with the `conditional_function` since it can only handle 2 branches -- case when `True` or case when `False`. Instead, you can modify the timeline by dynamically adding or removing nodes according to the chosen condition at the end of the choice trial: + +```javascript +const b1_t1 = {...}; +const b1_t2 = {...}; +const b1_t3 = {...}; +// So on and so forth +const b3_t3 = {...}; + +const branch_1 = [b1_t1, b1_t2, b1_t3]; +const branch_2 = [b2_t1, b2_t2, b2_t3]; +const branch_3 = [b3_t1, b3_t2, b3_t3]; + +const choice_confirm_trial = { + type: jsPsychHtmlKeyboardResponse, + stimulus: () => { + const choice = jsPsych.data.get().last(1).values()[0]; + return `You have chosen ${choice}.` + } + on_finish: () => { + switch(choice) { + case 1: + timeline.push(branch_1); + break; + case 2: + timeline.push(branch_2); + break; + case 3: + timeline.push(branch_3); + break; + case 5: + timeline.pop(); + break; + default: + break; + } + } +} + +const end_trial = { + type: jsPsychHtmlKeyboardResponse, + stimulus: 'End of experiment.' +} + +timeline.push(choice_confirm_trial, end_trial); +``` +During runtime, choices 1, 2 and 3 will dynamically add a different (nested) timeline, `branch_1`, `branch_2` and `branch_3` respectively, to the end of the main timeline. Choice 4 is caught at `start_node`, where the `loop_function` will resolve to `True` and repeat the welcome and choice trials. Finally, choice 5 removes the `end_trial` that was pushed to the timeline before runtime. + +### Exception cases for adding/removing timeline nodes dynamically +Adding or removing timeline nodes work as expected when the addition/removal occurs at a future point in the timeline relative to the current executing node, but not if it occurs before the current node. The example above works as expected becaues all added/removed nodes occur at the end of the timeline via `push()` and `pop()`, but if the branches were inserted at a point in the timeline that has already been executed, e.g. `timeline.splice(0, 0, branch_1)`, then `branch_1` will not be executed. + +In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again. For example, `start_node` is currently a looping timeline of this form `welcome`->`choice`. If we added an `on_finish` function to `choice_trial` that adds a timeline node to the start of the timeline, e.g. `hello`->`welcome`->`choice`, the `choice` trial will appear again as the next trial immediately after (and cause an infinite loop!). Similarly, if we tried to remove the `hello` trial from a looping timeline of the form `hello`->`welcome`->`choice` while executing the `welcome` trial, the `choice` trial will not be executed. + ## Timeline start and finish functions You can run a custom function at the start and end of a timeline node using the `on_timeline_start` and `on_timeline_finish` callback function parameters. These are functions that will run when the timeline starts and ends, respectively. diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index 1a660fd7ba..73012cb99d 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -75,6 +75,40 @@ describe("Timeline", () => { expect(timeline.children.length).toEqual(2); }); + it("respects dynamically added child node descriptions at the start", async () => { + TestPlugin.setManualFinishTrialMode(); + + const timelineDescription: TimelineArray = [{ type: TestPlugin }]; + const timeline = createTimeline(timelineDescription); + + const runPromise = timeline.run(); + expect(timeline.children.length).toEqual(1); + + timelineDescription.splice(0, 0, { timeline: [{ type: TestPlugin }] }); + await TestPlugin.finishTrial(); + await TestPlugin.finishTrial(); + await runPromise; + + expect(timeline.children.length).toEqual(2); + }); + + it("dynamically added child node descriptions before a node after it has been run", async () => { + TestPlugin.setManualFinishTrialMode(); + + const timelineDescription: TimelineArray = [{ type: TestPlugin }]; + const timeline = createTimeline(timelineDescription); + + const runPromise = timeline.run(); + expect(timeline.children.length).toEqual(1); + + await TestPlugin.finishTrial(); + timelineDescription.splice(0, 0, { timeline: [{ type: TestPlugin }] }); + await TestPlugin.finishTrial(); + await runPromise; + + expect(timeline.children.length).toEqual(1); + }); + it("respects dynamically removed end child node descriptions", async () => { TestPlugin.setManualFinishTrialMode(); From 3b0de40c318a670a38e945c0709b001643fbd156 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Tue, 5 Nov 2024 15:40:58 -0500 Subject: [PATCH 09/15] delete content page in timeline.md --- docs/overview/timeline.md | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index b8fa422967..b7b6391adb 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -1,24 +1,3 @@ -- [Creating an Experiment: The Timeline](#creating-an-experiment-the-timeline) - - [A single trial](#a-single-trial) - - [Multiple trials](#multiple-trials) - - [Nested timelines](#nested-timelines) - - [Timeline variables](#timeline-variables) - - [Using timeline variables in a function](#using-timeline-variables-in-a-function) - - [Random orders of trials](#random-orders-of-trials) - - [Sampling methods](#sampling-methods) - - [Sampling with replacement](#sampling-with-replacement) - - [Sampling with replacement, unequal probabilities](#sampling-with-replacement-unequal-probabilities) - - [Sampling without replacement](#sampling-without-replacement) - - [Repeating each trial a fixed number of times in a random order](#repeating-each-trial-a-fixed-number-of-times-in-a-random-order) - - [Alternating groups](#alternating-groups) - - [Custom sampling function](#custom-sampling-function) - - [Repeating a set of trials](#repeating-a-set-of-trials) - - [Looping timelines](#looping-timelines) - - [Conditional timelines](#conditional-timelines) - - [Modifying timelines at runtime](#modifying-timelines-at-runtime) - - [Exception cases for adding/removing timeline nodes dynamically](#exception-cases-for-addingremoving-timeline-nodes-dynamically) - - [Timeline start and finish functions](#timeline-start-and-finish-functions) - # Creating an Experiment: The Timeline To create an experiment using jsPsych, you need to specify a timeline that describes the structure of the experiment. The timeline is an ordered set of trials. You must create the timeline before launching the experiment. Most of the code you will write for an experiment will be code to create the timeline. This page walks through the creation of timelines, including very basic examples and more advanced features. From 02697e758f176736ed489a5997782b21c2e06893 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Wed, 6 Nov 2024 14:28:34 -0500 Subject: [PATCH 10/15] productive procrastination? second pass at timeline.md docs for dynamic node add/remove --- docs/overview/timeline.md | 80 +++++++++++++++++++++++---------------- 1 file changed, 47 insertions(+), 33 deletions(-) diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index b7b6391adb..dad2119962 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -469,7 +469,10 @@ jsPsych.run([pre_if_trial, if_node, after_if_trial]); ## Modifying timelines at runtime -Although this functionality can also be achieved through a combination of the `conditional_function` and the use of dynamic variables in the `stimulus` parameter, our timeline implementation allows you to dynamically insert or remove trials and nested timelines during runtime. For example, you may have a branching point in your experiment where the participant is given 4 choices, each leading to a different timeline: +Although this functionality can also be achieved through a combination of the `conditional_function` and the use of dynamic variables in the `stimulus` parameter, our timeline implementation allows you to dynamically insert or remove trials and nested timelines during runtime. + +### Inserting timeline nodes at runtime +For example, you may have a branching point in your experiment where the participant is given 3 choices, each leading to a different timeline: ```javascript const jspsych = initJsPsych(); @@ -482,23 +485,11 @@ const welcome_trial = { const choice_trial = { type: jsPsychHtmlKeyboardResponse, - stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish. Press 4 to see the welcome page and these options again (in case of stupidity-induced blindness). Press 5 to exit.' -} - -const start_node = { - timeline: [welcome_trial, choice_trial], - loop_function: function(data){ - if(jsPsych.pluginAPI.compareKeys(data.values()[0].response, '4')){ - return true; - } else { - return false; - } - } + stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish.', + choices: ['1','2','3'] } - -timeline.push(start_node); ``` -This would be trickier to implement with the `conditional_function` since it can only handle 2 branches -- case when `True` or case when `False`. Instead, you can modify the timeline by dynamically adding or removing nodes according to the chosen condition at the end of the choice trial: +This would be trickier to implement with the `conditional_function` since it can only handle 2 branches -- case when `True` or case when `False`. Instead, you can modify the timeline by modifying `choice_trial` to dynamically adding a timeline at the end of the choice trial according to the chosen condition: ```javascript const b1_t1 = {...}; @@ -511,45 +502,68 @@ const branch_1 = [b1_t1, b1_t2, b1_t3]; const branch_2 = [b2_t1, b2_t2, b2_t3]; const branch_3 = [b3_t1, b3_t2, b3_t3]; -const choice_confirm_trial = { +const choice_trial = { type: jsPsychHtmlKeyboardResponse, - stimulus: () => { - const choice = jsPsych.data.get().last(1).values()[0]; - return `You have chosen ${choice}.` + stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish.', + choices: ['1','2','3'], + on_finish: (data) => { + switch(data.response) { + case '1': + timeline.push(branch_1); + break; + case '2': + timeline.push(branch_2); + break; + case '3': + timeline.push(branch_3); + break; + } } - on_finish: () => { - switch(choice) { - case 1: +} +timeline.push(start_trial, choice_trial); +``` +During runtime, choices 1, 2 and 3 will dynamically add a different (nested) timeline, `branch_1`, `branch_2` and `branch_3` respectively, to the end of the main timeline. + +### Removing timeline nodes at runtime + +You can also remove upcoming timeline nodes from the timeline at runtime. To demonstrate this, we can modify the above example by adding a 4th choice and extra trial at the end of the timeline: + +```javascript +const choice_trial = { + type: jsPsychHtmlKeyboardResponse, + stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish. Press 4 to exit.', + choices: ['1','2','3', '4'], + on_finish: (data) => { + switch(data.response) { + case '1': timeline.push(branch_1); break; - case 2: + case '2': timeline.push(branch_2); break; - case 3: + case '3': timeline.push(branch_3); break; - case 5: + case '4': timeline.pop(); break; - default: - break; } } } const end_trial = { - type: jsPsychHtmlKeyboardResponse, - stimulus: 'End of experiment.' + type: JsPsychHtmlKeyboardResponse, + stimulus: 'End of experiment' } -timeline.push(choice_confirm_trial, end_trial); +timeline.push(start_trial, choice_trial, end_trial) ``` -During runtime, choices 1, 2 and 3 will dynamically add a different (nested) timeline, `branch_1`, `branch_2` and `branch_3` respectively, to the end of the main timeline. Choice 4 is caught at `start_node`, where the `loop_function` will resolve to `True` and repeat the welcome and choice trials. Finally, choice 5 removes the `end_trial` that was pushed to the timeline before runtime. +Now, if 1, 2 or 3 were chosen during runtime, the `end_trial` will run after the dynamically added timeline corresponding to the choice has been run; but if 4 was chosen, `end_trial` will be removed at runtime, and the timeline will terminate. ### Exception cases for adding/removing timeline nodes dynamically Adding or removing timeline nodes work as expected when the addition/removal occurs at a future point in the timeline relative to the current executing node, but not if it occurs before the current node. The example above works as expected becaues all added/removed nodes occur at the end of the timeline via `push()` and `pop()`, but if the branches were inserted at a point in the timeline that has already been executed, e.g. `timeline.splice(0, 0, branch_1)`, then `branch_1` will not be executed. -In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again. For example, `start_node` is currently a looping timeline of this form `welcome`->`choice`. If we added an `on_finish` function to `choice_trial` that adds a timeline node to the start of the timeline, e.g. `hello`->`welcome`->`choice`, the `choice` trial will appear again as the next trial immediately after (and cause an infinite loop!). Similarly, if we tried to remove the `hello` trial from a looping timeline of the form `hello`->`welcome`->`choice` while executing the `welcome` trial, the `choice` trial will not be executed. +In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again; and removing a timeline node at a point before the current node will cause the next node to be skipped. ## Timeline start and finish functions From d16b8da4136a1b153c18c81608173ba2c3486152 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Wed, 6 Nov 2024 14:43:59 -0500 Subject: [PATCH 11/15] less cutesy --- docs/overview/timeline.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index dad2119962..e2a644f182 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -504,7 +504,7 @@ const branch_3 = [b3_t1, b3_t2, b3_t3]; const choice_trial = { type: jsPsychHtmlKeyboardResponse, - stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish.', + stimulus: 'Press 1 for English. Press 2 for Mandarin. Press 3 for Spanish.', choices: ['1','2','3'], on_finish: (data) => { switch(data.response) { @@ -531,7 +531,7 @@ You can also remove upcoming timeline nodes from the timeline at runtime. To dem ```javascript const choice_trial = { type: jsPsychHtmlKeyboardResponse, - stimulus: 'Press 1 if you are a new participant. Press 2 for inquiries about an existing experiment run. Press 3 for Spanish. Press 4 to exit.', + stimulus: 'Press 1 for English. Press 2 for Mandarin. Press 3 for Spanish. Press 4 to exit.', choices: ['1','2','3', '4'], on_finish: (data) => { switch(data.response) { From d83fb260b229b0021eeb4df4fcd61b45050609a1 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Thu, 7 Nov 2024 12:29:07 -0500 Subject: [PATCH 12/15] third pass timeline.md --- docs/overview/timeline.md | 79 +++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index e2a644f182..3bf099f461 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -476,11 +476,11 @@ For example, you may have a branching point in your experiment where the partici ```javascript const jspsych = initJsPsych(); -let timeline = []; +let main_timeline = []; -const welcome_trial = { +const part1_trial = { type: jsPsychHtmlKeyboardResponse, - stimulus: 'Welcome!' + stimulus: 'Part 1' } const choice_trial = { @@ -492,15 +492,15 @@ const choice_trial = { This would be trickier to implement with the `conditional_function` since it can only handle 2 branches -- case when `True` or case when `False`. Instead, you can modify the timeline by modifying `choice_trial` to dynamically adding a timeline at the end of the choice trial according to the chosen condition: ```javascript -const b1_t1 = {...}; -const b1_t2 = {...}; -const b1_t3 = {...}; +const english_trial1 = {...}; +const english_trial2 = {...}; +const english_trial3 = {...}; // So on and so forth -const b3_t3 = {...}; +const spanish_trial3 = {...}; -const branch_1 = [b1_t1, b1_t2, b1_t3]; -const branch_2 = [b2_t1, b2_t2, b2_t3]; -const branch_3 = [b3_t1, b3_t2, b3_t3]; +const english_branch = [b1_t1, b1_t2, b1_t3]; +const mandarin_branch = [b2_t1, b2_t2, b2_t3]; +const spanish_branch = [b3_t1, b3_t2, b3_t3]; const choice_trial = { type: jsPsychHtmlKeyboardResponse, @@ -509,24 +509,24 @@ const choice_trial = { on_finish: (data) => { switch(data.response) { case '1': - timeline.push(branch_1); + main_timeline.push(english_branch); break; case '2': - timeline.push(branch_2); + main_timeline.push(mandarin_branch); break; case '3': - timeline.push(branch_3); + main_timeline.push(spanish_branch); break; } } } -timeline.push(start_trial, choice_trial); +main_timeline.push(part1_trial, choice_trial); ``` -During runtime, choices 1, 2 and 3 will dynamically add a different (nested) timeline, `branch_1`, `branch_2` and `branch_3` respectively, to the end of the main timeline. +During runtime, choices 1, 2 and 3 will dynamically add a different (nested) timeline, `english_branch`, `mandarin_branch` and `spanish_branch` respectively, to the end of the `main_timeline`. ### Removing timeline nodes at runtime -You can also remove upcoming timeline nodes from the timeline at runtime. To demonstrate this, we can modify the above example by adding a 4th choice and extra trial at the end of the timeline: +You can also remove upcoming timeline nodes from a timeline at runtime. To demonstrate this, we can modify the above example by adding a 4th choice to `choice_trial` and another (nested) timeline to the tail of `main_timeline`: ```javascript const choice_trial = { @@ -536,34 +536,57 @@ const choice_trial = { on_finish: (data) => { switch(data.response) { case '1': - timeline.push(branch_1); + main_timeline.push(english_branch); break; case '2': - timeline.push(branch_2); + main_timeline.push(mandarin_branch); break; case '3': - timeline.push(branch_3); + main_timeline.push(spanish_branch); break; case '4': - timeline.pop(); + main_timeline.pop(); break; } } } -const end_trial = { - type: JsPsychHtmlKeyboardResponse, - stimulus: 'End of experiment' -} +const part2_timeline = [ + { + type: JsPsychHtmlKeyboardResponse, + stimulus: 'Part 2' + } + // ...the rest of the part 2 trials +] -timeline.push(start_trial, choice_trial, end_trial) +main_timeline.push(part1_trial, choice_trial, part2_timeline) ``` -Now, if 1, 2 or 3 were chosen during runtime, the `end_trial` will run after the dynamically added timeline corresponding to the choice has been run; but if 4 was chosen, `end_trial` will be removed at runtime, and the timeline will terminate. +Now, if 1, 2 or 3 were chosen during runtime, `part2_timeline` will run after the dynamically added timeline corresponding to the choice (`english_branch` | `mandarin_branch` | `spanish_branch`) has been run; but if 4 was chosen, `part2_timeline` will be removed at runtime, and `main_timeline` will terminate. ### Exception cases for adding/removing timeline nodes dynamically -Adding or removing timeline nodes work as expected when the addition/removal occurs at a future point in the timeline relative to the current executing node, but not if it occurs before the current node. The example above works as expected becaues all added/removed nodes occur at the end of the timeline via `push()` and `pop()`, but if the branches were inserted at a point in the timeline that has already been executed, e.g. `timeline.splice(0, 0, branch_1)`, then `branch_1` will not be executed. +Adding or removing timeline nodes work as expected when the addition/removal occurs at a future point in the timeline relative to the current executing node, but not if it occurs before the current node. The example above works as expected becaues all the node(s) added (`english_branch` | `mandarin_branch` | `spanish_branch`) or removed (`part2_timeline`) occur at the end of the timeline via `push()` and `pop()`. If a node was inserted at a point in the timeline that has already been executed, it will not be executed: + +```javascript +const choice_trial = { + type: jsPsychHtmlKeyboardResponse, + stimulus: 'Press 1 for English. Press 2 for Mandarin. Press 3 for Spanish. Press 4 to exit.', + choices: ['1','2','3', '4'], + on_finish: (data) => { + switch(data.response) { + case '1': + main_timeline.splice(0,0,english_branch); // Adds english_branch to the start of main_timeline + break; + case '2': + main_timeline.push(mandarin_branch); + break; + + ... + +main_timeline.push(part1_trial, choice_trial); +``` +In the above implementation of `choice_trial`, choice 1 adds `english_branch` to the start of `main_timeline`, such that `main_timeline = [english_branch, part1_trial, choice_trial]`, but because the execution of `main_timeline` is past the first node at this point in runtime, the newly added `english_branch` will not be executed. Similarly, modifying `case '1'` in `choice_trial` to remove `part1_trial` will not change any behavior in the timeline. -In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again; and removing a timeline node at a point before the current node will cause the next node to be skipped. +DON'T DO THIS: In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again; and removing a timeline node at a point before the current node will cause the next node to be skipped. ## Timeline start and finish functions From b39d3e70016a0b5c20970f02f144d32b81b6e591 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Thu, 7 Nov 2024 14:37:23 -0500 Subject: [PATCH 13/15] add admonition --- docs/overview/timeline.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index 3bf099f461..1ab4873124 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -469,9 +469,9 @@ jsPsych.run([pre_if_trial, if_node, after_if_trial]); ## Modifying timelines at runtime -Although this functionality can also be achieved through a combination of the `conditional_function` and the use of dynamic variables in the `stimulus` parameter, our timeline implementation allows you to dynamically insert or remove trials and nested timelines during runtime. +Although this functionality can also be achieved through a combination of the `conditional_function` and the use of dynamic variables in the `stimulus` parameter, our timeline implementation allows you to dynamically add or remove trials and nested timelines during runtime. -### Inserting timeline nodes at runtime +### Adding timeline nodes at runtime For example, you may have a branching point in your experiment where the participant is given 3 choices, each leading to a different timeline: ```javascript @@ -564,7 +564,7 @@ main_timeline.push(part1_trial, choice_trial, part2_timeline) Now, if 1, 2 or 3 were chosen during runtime, `part2_timeline` will run after the dynamically added timeline corresponding to the choice (`english_branch` | `mandarin_branch` | `spanish_branch`) has been run; but if 4 was chosen, `part2_timeline` will be removed at runtime, and `main_timeline` will terminate. ### Exception cases for adding/removing timeline nodes dynamically -Adding or removing timeline nodes work as expected when the addition/removal occurs at a future point in the timeline relative to the current executing node, but not if it occurs before the current node. The example above works as expected becaues all the node(s) added (`english_branch` | `mandarin_branch` | `spanish_branch`) or removed (`part2_timeline`) occur at the end of the timeline via `push()` and `pop()`. If a node was inserted at a point in the timeline that has already been executed, it will not be executed: +Adding or removing timeline nodes work as expected when the addition/removal occurs at a future point in the timeline relative to the current executing node, but not if it occurs before the current node. The example above works as expected becaues all the node(s) added (`english_branch` | `mandarin_branch` | `spanish_branch`) or removed (`part2_timeline`) occur at the end of the timeline via `push()` and `pop()`. If a node was added at a point in the timeline that has already been executed, it will not be executed: ```javascript const choice_trial = { @@ -584,9 +584,10 @@ const choice_trial = { main_timeline.push(part1_trial, choice_trial); ``` -In the above implementation of `choice_trial`, choice 1 adds `english_branch` to the start of `main_timeline`, such that `main_timeline = [english_branch, part1_trial, choice_trial]`, but because the execution of `main_timeline` is past the first node at this point in runtime, the newly added `english_branch` will not be executed. Similarly, modifying `case '1'` in `choice_trial` to remove `part1_trial` will not change any behavior in the timeline. +In the above implementation of `choice_trial`, choice 1 adds `english_branch` at the start of `main_timeline`, such that `main_timeline = [english_branch, part1_trial, choice_trial]`, but because the execution of `main_timeline` is past the first node at this point in runtime, the newly added `english_branch` will not be executed. Similarly, modifying `case '1'` in `choice_trial` to remove `part1_trial` will not change any behavior in the timeline. -DON'T DO THIS: In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again; and removing a timeline node at a point before the current node will cause the next node to be skipped. +!!! danger "Dynamically adding/removing nodes in a looping timeline" +In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again; and removing a timeline node at a point before the current node will cause the next node to be skipped. ## Timeline start and finish functions From 8e6eda2407eb3590ea8d46965c5d4e6d32243236 Mon Sep 17 00:00:00 2001 From: Cherrie Chang Date: Thu, 7 Nov 2024 14:39:37 -0500 Subject: [PATCH 14/15] fix admonition --- docs/overview/timeline.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/overview/timeline.md b/docs/overview/timeline.md index 1ab4873124..b33d53b0ed 100644 --- a/docs/overview/timeline.md +++ b/docs/overview/timeline.md @@ -586,7 +586,7 @@ main_timeline.push(part1_trial, choice_trial); ``` In the above implementation of `choice_trial`, choice 1 adds `english_branch` at the start of `main_timeline`, such that `main_timeline = [english_branch, part1_trial, choice_trial]`, but because the execution of `main_timeline` is past the first node at this point in runtime, the newly added `english_branch` will not be executed. Similarly, modifying `case '1'` in `choice_trial` to remove `part1_trial` will not change any behavior in the timeline. -!!! danger "Dynamically adding/removing nodes in a looping timeline" +!!! danger In the case of a looping timeline, adding a timeline node at a point before the current node will cause the current node to be executed again; and removing a timeline node at a point before the current node will cause the next node to be skipped. ## Timeline start and finish functions From 8665717cfb3d84ac69528c65c0e958f3e761e857 Mon Sep 17 00:00:00 2001 From: cchang-vassar <79338042+cchang-vassar@users.noreply.github.com> Date: Wed, 20 Nov 2024 11:56:38 -0500 Subject: [PATCH 15/15] delete adding node at start test --- packages/jspsych/src/timeline/Timeline.spec.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/packages/jspsych/src/timeline/Timeline.spec.ts b/packages/jspsych/src/timeline/Timeline.spec.ts index 73012cb99d..88688c37f5 100644 --- a/packages/jspsych/src/timeline/Timeline.spec.ts +++ b/packages/jspsych/src/timeline/Timeline.spec.ts @@ -75,23 +75,6 @@ describe("Timeline", () => { expect(timeline.children.length).toEqual(2); }); - it("respects dynamically added child node descriptions at the start", async () => { - TestPlugin.setManualFinishTrialMode(); - - const timelineDescription: TimelineArray = [{ type: TestPlugin }]; - const timeline = createTimeline(timelineDescription); - - const runPromise = timeline.run(); - expect(timeline.children.length).toEqual(1); - - timelineDescription.splice(0, 0, { timeline: [{ type: TestPlugin }] }); - await TestPlugin.finishTrial(); - await TestPlugin.finishTrial(); - await runPromise; - - expect(timeline.children.length).toEqual(2); - }); - it("dynamically added child node descriptions before a node after it has been run", async () => { TestPlugin.setManualFinishTrialMode();