Skip to content

bluebonesx/psytask

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

28 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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

Warning

In early stages, there will be frequent breaking changes, please pin the version number.

via NPM:

npm create psytask # create a project
npm install psytask # only framework
npm install @psytask/component vanjs-core vanjs-ext # optional: use VanJS

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/[email protected]",
      "vanjs-ext": "https://cdn.jsdelivr.net/npm/[email protected]"
    }
  }
</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 scene.

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 a object includes Node and Data Getter:

  • Props means show params that control the display of the scene.
  • Node is the string or element or array, which be 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 listenr 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 mount to this.root.

Note

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

Show

When you call await scene.show(patchProps), it will excute 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 timer by this.options.timer and wait it to stop.
  • Listeners added by this.on('frame') will be called when timer is running.
  • Hide this.root when timer is stoped, 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 bunlde 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/[email protected]/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/[email protected]/+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

JavaScript Framework for Psychology tasks. Compatible with the jsPsych plugin.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published