Skip to content

bluebonesx/psytask

Repository files navigation

PsyTask

NPM Version NPM Downloads jsDelivr hits (npm)

JavaScript Framework for Psychology tasks. Make development like making PPTs.

Compared to others, it:

  • Easier and more flexible
  • Higher time precision. See benchmark
  • Smaller bundle size, Faster loading speed. See benchmark
  • Type-Safe

Integration with:

API Docs | Benchmark | Tests | Play it now ! 🥳

Install

Note

The project is in early development. Please pin the version when using it.

via NPM:

npm create psytask # optional: use template
npm install psytask # only package
npm install @psytask/component vanjs-core vanjs-ext # optional: use components

via CDN:

<!-- add required packages  -->
<script type="importmap">
  {
    "imports": {
      "psytask": "https://cdn.jsdelivr.net/npm/psytask@1/dist/index.min.js",
      "@psytask/core": "https://cdn.jsdelivr.net/npm/@psytask/core@1/dist/index.min.js",
      "@psytask/components": "https://cdn.jsdelivr.net/npm/@psytask/components@1/dist/index.min.js",
      "vanjs-core": "https://cdn.jsdelivr.net/npm/vanjs-core@1.6",
      "vanjs-ext": "https://cdn.jsdelivr.net/npm/vanjs-ext@0.6"
    }
  }
</script>
<!-- load packages -->
<script type="module">
  import { createApp } from 'psytask';

  using app = await creaeApp();
</script>

Warning

PsyTask uses the modern JavaScript using keyword for automatic resource cleanup.

For CDN usage in old browsers that don't support the using keyword, you will see Uncaught SyntaxError: Unexpected identifier 'app'. You need to change the code:

// Instead of: using app = await createApp();
const app = await createApp();
// ... your code ...
app.emit('dispose'); // Manually clean up when done

Or, you can use the bundlers (like Vite, Bun, etc.) to transpile it.

Usage

The psychology tasks are just like PPTs; they both have a series of scenes. So writing a task only requires 2 steps: creating and showing scenes.

Create Scene

All you need is Component:

import { Grating, adapter } from '@psytask/components';

using simpleText = app.scene(
  // component
  Grating,
  // scene options
  {
    adapter, // VanJS support
    defaultProps: { type: Math.sin, size: 100, sf: 0.02 }, // show params
    duration: 1e3, // show 1000 ms
    close_on: 'key: ', // close on space key
  },
);

Show Scene

Override default props or options:

const data = await scene.show({ text: 'Press F or J' }); // new props
const data = await scene.config({ duration: 1e3 }).show(); // new options

Block:

import { RandomSampling, StairCase } from 'psytask';

// fixed sequence
for (const text of ['A', 'B', 'C']) {
  await scene.show({ text });
}

// random sequence
for (const text of RandomSampling({
  candidates: ['A', 'B', 'C'],
  sample: 10,
  replace: true,
})) {
  await scene.show({ text });
}

// staircase
const staircase = StairCase({
  start: 10,
  step: 1,
  up: 3,
  down: 1,
  reversals: 6,
  min: 1,
  max: 12,
  trial: 20,
});
for (const value of staircase) {
  const data = await scene.show({ text: value });
  const correct = data.response_key === 'f';
  staircase.response(correct); // set response
}

Data Collection

using dc = app.collector('data.csv');

for (const text of ['A', 'B', 'C']) {
  const data = await scene.show({ text });

  // `frame_times` will be recorded automatically
  const start_time = /** @type {number} */ (data.frame_times[0]);

  // add a row
  dc.add({
    text,
    response: data.response_key,
    rt: data.response_time - start_time,
    correct: data.response_key === 'f',
  });
}

dc.final(); // file content
dc.download(); // download file

Learn More

Component

It a function that inputs Props and outputs an object includes Node and Data Getter:

  • Props means the show parameters that control the display of the scene.
  • Node is a string or element, or array, that is mounted to the scene root element.
  • Data Getter is used to get generated data.
const Component = (props) => {
  const ctx = getCurrentScene();
  return { node: '', data: () => ({}) };
};
const Component = (props) => 'text node';
const Component = (props) => document.createElement('div');
const Component = (props) => ['text node', document.createElement('div')];

Caution

You shouldn't modify props, whatever, as it may change the default props. See one-way data flow in Redux and Vue.

A practical example:

import { on, getCurrentScene } from 'psytask';
import { ImageStim, adapter } from '@psytask/components';
import van from 'vanjs-core';

const { div } = van.tags;
const Component =
  /** @param {{ text: string }} props */
  (props) => {
    /** @type {{ response_key: string; response_time: number }} */
    let data;
    const ctx = getCurrentScene();

    // add DOM event listener
    const cleanup = on(ctx.root, 'keydown', (e) => {
      if (e.key !== 'f' || e.key !== 'j') return;
      data = { response_key: e.key, response_time: e.timeStamp };
      ctx.close(); // close on 'f' or 'j'
    });

    ctx
      // reset data on show
      .on('show', () => {
        data = { response_key: '', response_time: 0 };
      })
      // remove DOM event listener on dispose
      .on('dispose', cleanup);

    // Return the element and data getter
    return {
      node: div(
        // use other Component
        ImageStim({ image: new ImageData(1) }),
      ),
      data: () => data,
    };
  };

Tip

Use JSDoc Comment to get type hint in JavaScript.

Setup

When you call app.scene(Component, { adapter, defaultProps }), it will use adapter.render to call Component with defaultProps once, then Node will be mounted to this.root.

Note

The component will be called only once; the following DOM update will be triggered by the Props update. See reactivity.

Show

When you call await scene.show(patchProps), it will execute the following process:

  • Update props: merge patch props with default props to update current props, which will trigger reactivity update.
  • Listeners added by this.on('show') will be called.
  • Display and focus this.root, it will be displayed on the screen in the next frame.
  • Create a timer by this.options.timer and wait for it to stop.
  • Listeners added by this.on('frame') will be called when the timer is running.
  • Hide this.root when the timer is stopped, it will be hidden on the screen in the next frame.
  • Listeners added by this.on('close') will be called.
  • Merge the timer records and the data from Data Getter.
graph LR
a[update props] --> l1[on show] --> b[display & focus DOM] --> d[wait timer] --> l2[on frame] --> d --> e[hide root] --> l3[on close] --> f[merge data]
Loading

Reactivity

Stay tuned...

Better to see: VanJS tutorial, Vue reactivity

The bundle size of PsyTask is 1/12 of labjs, 1/50 of jspsych, and 1/260 of psychojs.

xychart
    title "Bundle Size (KB)"
    x-axis [psytask, labjs, jspsych, psychojs]
    y-axis 0 --> 2600
    bar [10.67, 122.45, 502.06, 2598.33]
Loading

Integration

npm i @psytask/jspsych @jspsych/plugin-cloze
npm i -d jspsych # optional: for type hint

Or using CDN:

<!-- load jspsych css-->
<link
  rel="stylesheet"
  href="https://cdn.jsdelivr.net/npm/jspsych@8.2.2/css/jspsych.css"
/>
<!-- add packages -->
<script type="importmap">
  {
    "imports": {
      ...
      "@psytask/jspsych": "https://cdn.jsdelivr.net/npm/@psytask/jspsych@1/dist/index.min.js",
      "@jspsych/plugin-cloze": "https://cdn.jsdelivr.net/npm/@jspsych/plugin-cloze@2.2.0/+esm"
    }
  }
</script>

Important

For CDNer, you should add the +esm after the jspsych plugin CDN URL, because jspsych plugins do not release ESM versions. Or you can use esm.sh.

import { jsPsychStim } from '@psytask/jspsych';
import Cloze from '@jspsych/plugin-cloze';

using jspsych = app.scene(jsPsychStim, {
  defaultProps: {
    type: Cloze,
    text: 'aba%%aba',
    check_answers: true,
  },
});
const data = await jspsych.show();
<!-- add jatos script -->
<script src="jatos.js"></script>
// wait for jatos loading
await new Promise((r) => jatos.onLoad(r));

using dc = app.collector().on('add', (row) => {
  // send data to JATOS server
  jatos.appendResultData(row);
});

About

The world's smallest JavaScript behavioral experiment framework

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors