1
0
mirror of https://github.com/psychopy/psychojs.git synced 2025-05-10 10:40:54 +00:00

merge with 2024.2.0

This commit is contained in:
lightest 2024-03-28 20:55:42 +00:00
commit 9dc0fa185b
33 changed files with 4933 additions and 264 deletions

View File

@ -53,7 +53,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v2
with:
node-version: '14'
node-version: '15'
- name: Cache modules psychojs_testing
uses: actions/cache@v2
env:

View File

@ -45,7 +45,7 @@ jobs:
- name: Setup node
uses: actions/setup-node@v1
with:
node-version: '12'
node-version: '15'
# START: install psychojs_testing
- name: Checkout psychojs_testing

25
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Build Branch
on: workflow_dispatch
jobs:
build_all:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
with:
path: app
- uses: actions/setup-node@master
with:
node-version: 19
- name: Install Node dependencies
run: |
cd app
npm install
- name: Build
run: |
cd app
echo "testing GITHUB_REF with details availability: ${GITHUB_REF#refs/heads/}"
npm run build:js && npm run build:css
echo "executing ls out on the directory:"
ls out

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.vscode/
dist
out
node_modules

2226
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "psychojs",
"version": "2022.3.1",
"version": "2024.1.0",
"private": true,
"description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments",
"license": "MIT",
@ -15,6 +15,9 @@
},
"main": "./src/index.js",
"scripts": {
"dev": "vite",
"vitebuild": "vite build",
"preview": "vite preview",
"build": "npm run build:js && npm run build:css && npm run build:docs",
"build:css": "node ./scripts/build.css.cjs",
"build:docs": "jsdoc src -c jsdoc.json & cp jsdoc.css docs/styles/",
@ -31,15 +34,19 @@
"a11y-dialog": "^7.5.0",
"docdash": "^1.2.0",
"esbuild-plugin-glsl": "^1.0.5",
"gifuct-js": "^2.1.2",
"howler": "^2.2.1",
"log4javascript": "github:Ritzlgrmft/log4javascript",
"pako": "^1.0.10",
"pixi-filters": "^5.0.0",
"pixi.js-legacy": "^6.0.4",
"seedrandom": "^3.0.5",
"tone": "^14.7.77",
"xlsx": "^0.17.0"
"xlsx": "^0.18.5"
},
"devDependencies": {
"vite": "^5.1.6",
"vite-plugin-glsl": "^1.2.1",
"csslint": "^1.0.5",
"dprint": "^0.15.3",
"esbuild": "^0.12.1",

View File

@ -351,7 +351,13 @@ export class EventManager
{
const timestamp = MonotonicClock.getReferenceTime();
let code = event.code;
// Note: we are using event.key since we are interested in the input character rather than
// the physical key position on the keyboard, i.e. we need to take into account the keyboard
// layout
// See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding
// event.code's lack of suitability
let code = EventManager._pygletMap[event.key];
// let code = event.code;
// take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge):
if (typeof code === "undefined")

View File

@ -50,6 +50,9 @@ export class GUI
{
this._psychoJS = psychoJS;
// info fields excluded from the GUI:
this._excludedInfo = {};
// gui listens to RESOURCE events from the server manager:
psychoJS.serverManager.on(ServerManager.Event.RESOURCE, (signal) =>
{
@ -87,9 +90,6 @@ export class GUI
requireParticipantClick = GUI.DEFAULT_SETTINGS.DlgFromDict.requireParticipantClick
})
{
// get info from URL:
const infoFromUrl = util.getUrlParameters();
this._progressBarMax = 0;
this._allResourcesDownloaded = false;
this._requiredKeys = [];
@ -113,6 +113,19 @@ export class GUI
self._dialogComponent.tStart = t;
self._dialogComponent.status = PsychoJS.Status.STARTED;
// prepare the info fields excluded from the GUI, including those from the URL:
const excludedInfo = {};
for (let key in self._excludedInfo)
{
excludedInfo[key.trim().toLowerCase()] = self._excludedInfo[key];
}
const infoFromUrl = util.getUrlParameters();
infoFromUrl.forEach((value, key) =>
{
excludedInfo[key.trim().toLowerCase()] = value;
});
// if the experiment is licensed, and running on the license rather than on credit,
// we use the license logo:
if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER
@ -130,7 +143,13 @@ export class GUI
markup += "<div class='dialog-content'>";
// alert title and close button:
markup += `<div id='experiment-dialog-title' class='dialog-title'><p>${title}</p><button id='dialogClose' class='dialog-close' data-a11y-dialog-hide aria-label='Cancel Experiment'>&times;</button></div>`;
markup += "<div id='experiment-dialog-title' class='dialog-title'>";
markup += `<p>${title}</p>`;
markup += "<button id='dialogClose' class='dialog-close' data-a11y-dialog-hide aria-label='Cancel Experiment'>&times;</button>";
markup += "</div>";
// everything above the buttons is in a scrollable container:
markup += "<div class='scrollable-container'>";
// logo, if need be:
if (typeof logoUrl === "string")
@ -139,14 +158,16 @@ export class GUI
}
// add a combobox or text areas for each entry in the dictionary:
let atLeastOneIncludedKey = false;
Object.keys(dictionary).forEach((key, keyIdx) =>
{
const value = dictionary[key];
const keyId = "form-input-" + keyIdx;
// only create an input if the key is not in the URL:
let inUrl = false;
const cleanedDictKey = key.trim().toLowerCase();
const isIncluded = !(cleanedDictKey in excludedInfo);
/*let inUrl = false;
infoFromUrl.forEach((urlValue, urlKey) =>
{
const cleanedUrlKey = urlKey.trim().toLowerCase();
@ -155,10 +176,13 @@ export class GUI
inUrl = true;
// break;
}
});
});*/
if (!inUrl)
if (isIncluded)
// if (!inUrl)
{
atLeastOneIncludedKey = true;
markup += `<label for='${keyId}'> ${key} </label>`;
// if the field is required:
@ -185,7 +209,7 @@ export class GUI
markup += "</select>";
}
// otherwise we use a single string input:
// otherwise we use a single string input:
//if (typeof value === 'string')
else
{
@ -199,17 +223,27 @@ export class GUI
markup += "<p class='validateTips'>Fields marked with an asterisk (*) are required.</p>";
}
markup += "</div>"; // scrollable-container
// separator, if need be:
if (atLeastOneIncludedKey)
{
markup += "<hr>";
}
// progress bar:
markup += `<hr><div id='progressMsg' class='progress-msg'>${self._progressMessage}</div>`;
markup += `<div id='progressMsg' class='progress-msg'>${self._progressMessage}</div>`;
markup += "<div class='progress-container'><div id='progressBar' class='progress-bar'></div></div>";
// buttons:
markup += "<hr>";
markup += "<div class='dialog-button-group'>";
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Cancel Experiment'>Cancel</button>";
if (self._requireParticipantClick)
{
markup += "<button id='dialogOK' class='dialog-button disabled' aria-label='Start Experiment'>Ok</button>";
}
markup += "</div>"; // button-group
markup += "</div></div>";
@ -346,14 +380,18 @@ export class GUI
{
const error = this._userFriendlyError(errorCode);
markup += `<div id='experiment-dialog-title' class='dialog-title ${error.class}'><p>${error.title}</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>${error.text}</p>`;
markup += "</div>";
}
else
{
markup += `<div id='experiment-dialog-title' class='dialog-title dialog-error'><p>Error</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>Unfortunately we encountered the following error:</p>`;
markup += stackCode;
markup += "<p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>";
markup += "</div>";
}
}
@ -361,27 +399,36 @@ export class GUI
else if (typeof warning !== "undefined")
{
markup += `<div id='experiment-dialog-title' class='dialog-title dialog-warning'><p>Warning</p></div>`;
markup += "<div class='scrollable-container'>";
markup += `<p>${warning}</p>`;
markup += "</div>";
}
// we are displaying a message:
else if (typeof message !== "undefined")
{
markup += `<div id='experiment-dialog-title' class='dialog-title'><p>Message</p></div>`;
markup += "<div id='experiment-dialog-title' class='dialog-title'><p>Message</p></div>";
markup += "<div class='scrollable-container'>";
markup += `<p>${message}</p>`;
markup += "</div>";
}
if (showOK || showCancel)
{
markup += "<hr>";
}
if (showCancel)
if (showCancel || showOK)
{
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Close dialog'>Cancel</button>";
}
if (showOK)
{
markup += "<button id='dialogOK' class='dialog-button' aria-label='Close dialog'>Ok</button>";
markup += "<div class='button-group'>";
if (showCancel)
{
markup += "<button id='dialogCancel' class='dialog-button' aria-label='Close dialog'>Cancel</button>";
}
if (showOK)
{
markup += "<button id='dialogOK' class='dialog-button' aria-label='Close dialog'>Ok</button>";
}
markup += "</div>"; // button-group
}
markup += "</div></div>";

View File

@ -354,7 +354,13 @@ export class Keyboard extends PsychObject
*/
self._previousKeydownKey = event.key;
let code = event.code;
// Note: we are using event.key since we are interested in the input character rather than
// the physical key position on the keyboard, i.e. we need to take into account the keyboard
// layout
// See https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code for a comment regarding
// event.code's lack of suitability
let code = EventManager._pygletMap[event.key];
// let code = event.code;
// take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge):
if (typeof code === "undefined")
@ -394,7 +400,9 @@ export class Keyboard extends PsychObject
self._previousKeydownKey = undefined;
let code = event.code;
// Note: see above for explanation regarding the use of event.key in lieu of event.code
let code = EventManager._pygletMap[event.key];
// let code = event.code;
// take care of legacy Microsoft Edge:
if (typeof code === "undefined")

View File

@ -530,6 +530,7 @@ export class PsychoJS
const response = { origin: "PsychoJS.quit", context: "when terminating the experiment" };
this._experiment.experimentEnded = true;
this._experiment.isCompleted = isCompleted;
this.status = PsychoJS.Status.STOPPED;
const isServerEnv = (this.getEnvironment() === ExperimentHandler.Environment.SERVER);
@ -601,7 +602,7 @@ export class PsychoJS
if (showOK)
{
let text = "Thank you for your patience.<br/><br/>";
let text = "Thank you for your patience.";
text += (typeof message !== "undefined") ? message : "Goodbye!";
this._gui.dialog({
message: text,

View File

@ -314,6 +314,46 @@ export class ServerManager extends PsychObject
return pathStatusData.data;
}
/**
* Get full data of a resource.
*
* @name module:core.ServerManager#getFullResourceData
* @function
* @public
* @param {string} name - name of the requested resource
* @param {boolean} [errorIfNotDownloaded = false] whether or not to throw an exception if the
* resource status is not DOWNLOADED
* @return {Object} full available data for resource, or undefined if the resource has been registered
* but not downloaded yet.
* @throws {Object.<string, *>} exception if no resource with that name has previously been registered
*/
getFullResourceData (name, errorIfNotDownloaded = false)
{
const response = {
origin: "ServerManager.getResource",
context: "when getting the value of resource: " + name,
};
const pathStatusData = this._resources.get(name);
if (typeof pathStatusData === "undefined")
{
// throw { ...response, error: 'unknown resource' };
throw Object.assign(response, { error: "unknown resource" });
}
if (errorIfNotDownloaded && pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
{
throw Object.assign(response, {
error: name + " is not available for use (yet), its current status is: "
+ util.toString(pathStatusData.status),
});
}
return pathStatusData;
}
/**
* Release a resource.
*
@ -662,6 +702,19 @@ export class ServerManager extends PsychObject
}
}
cacheResourceData (name, dataToCache)
{
const pathStatusData = this._resources.get(name);
if (typeof pathStatusData === "undefined")
{
// throw { ...response, error: 'unknown resource' };
throw Object.assign(response, { error: "unknown resource" });
}
pathStatusData.cachedData = dataToCache;
}
/**
* Block the experiment until the specified resources have been downloaded.
*
@ -1265,7 +1318,7 @@ export class ServerManager extends PsychObject
const pathExtension = (pathParts.length > 1) ? pathParts.pop() : undefined;
// preload.js with forced binary:
if (["csv", "odp", "xls", "xlsx", "json"].indexOf(extension) > -1)
if (["csv", "odp", "xls", "xlsx", "json", "gif"].indexOf(extension) > -1)
{
preloadManifest.push(/*new createjs.LoadItem().set(*/ {
id: name,
@ -1293,7 +1346,7 @@ export class ServerManager extends PsychObject
}
// font files:
else if (["ttf", "otf", "woff", "woff2"].indexOf(pathExtension) > -1)
else if (["ttf", "otf", "woff", "woff2","eot"].indexOf(pathExtension) > -1)
{
fontResources.push(name);
}
@ -1310,7 +1363,7 @@ export class ServerManager extends PsychObject
preloadManifest.push(/*new createjs.LoadItem().set(*/ {
id: name,
src: pathStatusData.path,
crossOrigin: "Anonymous",
crossOrigin: "Anonymous"
} /*)*/);
}
}

View File

@ -13,6 +13,7 @@ import { MonotonicClock } from "../util/Clock.js";
import { Color } from "../util/Color.js";
import { PsychObject } from "../util/PsychObject.js";
import { Logger } from "./Logger.js";
import { hasTouchScreen } from "../util/Util.js";
/**
* <p>Window displays the various stimuli of the experiment.</p>
@ -151,7 +152,7 @@ export class Window extends PsychObject
}
this._rootContainer.destroy();
if (document.body.contains(this._renderer.view))
{
document.body.removeChild(this._renderer.view);
@ -180,7 +181,7 @@ export class Window extends PsychObject
{
// gets updated frame by frame
const lastDelta = this.psychoJS.scheduler._lastDelta;
const fps = lastDelta === 0 ? 60.0 : 1000 / lastDelta;
const fps = (lastDelta === 0) ? 60.0 : (1000.0 / lastDelta);
return fps;
}
@ -314,7 +315,7 @@ export class Window extends PsychObject
*/
removePixiObject(pixiObject)
{
this._stimsContainer.removeChild(pixiObject);
this._stimsContainer.removeChild(pixiObject);
}
/**
@ -475,11 +476,11 @@ export class Window extends PsychObject
// create a top-level PIXI container:
this._rootContainer = new PIXI.Container();
this._rootContainer.addChild(this._backgroundSprite, this._stimsContainer);
// sorts children according to their zIndex value. Higher zIndex means it will be moved towards the end of the array,
// and thus rendered on top of previous one.
this._rootContainer.sortableChildren = true;
this._rootContainer.interactive = true;
this._rootContainer.filters = [this._adjustmentFilter];
@ -575,6 +576,17 @@ export class Window extends PsychObject
// update the renderer size and the Window's stimuli whenever the browser's size or orientation change:
this._resizeCallback = (e) =>
{
// if the user device is a mobile phone or tablet (we use the presence of a touch screen as a
// proxy), we need to detect whether the change in size is due to the appearance of a virtual keyboard
// in which case we do not want to resize the canvas. This is rather tricky and so we resort to
// the below trick. It would be better to use the VirtualKeyboard API, but it is not widely
// available just yet, as of 2023-06.
const keyboardHeight = 300;
if (hasTouchScreen() && (window.screen.height - window.visualViewport.height) > keyboardHeight)
{
return;
}
Window._resizePixiRenderer(this, e);
this._backgroundSprite.width = this._size[0];
this._backgroundSprite.height = this._size[1];

View File

@ -276,6 +276,7 @@ export class ExperimentHandler extends PsychObject
}
let data = this._trialsData;
// if the experiment data have to be cleared, we first make a copy of them:
if (clear)
{
@ -351,6 +352,19 @@ export class ExperimentHandler extends PsychObject
}
}
/**
* Get the results of the experiment as a .csv string, ready to be uploaded or stored.
*
* @return {string} a .csv representation of the experiment results.
*/
getResultAsCsv()
{
// note: we use the XLSX library as it automatically deals with header, takes care of quotes,
// newlines, etc.
const worksheet = XLSX.utils.json_to_sheet(this._trialsData);
return "\ufeff" + XLSX.utils.sheet_to_csv(worksheet);
}
/**
* Get the attribute names and values for the current trial of a given loop.
* <p> Only info relating to the trial execution are returned.</p>

View File

@ -26,13 +26,12 @@ body {
/* Project and resource dialogs */
.dialog-container label,
.dialog-container input,
.dialog-container select {
box-sizing: border-box;
display: block;
padding-bottom: 0.5em;
box-sizing: border-box;
display: block;
padding-bottom: 0.5em;
}
.dialog-container input.text,
@ -40,6 +39,13 @@ body {
margin-bottom: 1em;
padding: 0.5em;
width: 100%;
height: 34px;
border: 1px solid #767676;
border-radius: 2px;
background: #ffffff;
color: #333;
font-size: 14px;
}
.dialog-container fieldset {
@ -71,12 +77,19 @@ body {
}
.dialog-content {
display: flex;
flex-direction: column;
row-gap: 0;
margin: auto;
z-index: 2;
position: relative;
width: 500px;
max-width: 88vw;
/*max-height: 90vh;*/
max-height: 93%;
padding: 0.5em;
border-radius: 2px;
@ -88,11 +101,24 @@ body {
box-shadow: 1px 1px 3px #555555;
}
.dialog-content .scrollable-container {
height: 100%;
padding: 0 0.5em;
overflow-x: hidden;
overflow-y: auto;
}
.dialog-content hr {
width: 100%;
}
.dialog-title {
padding: 0.5em;
margin-bottom: 1em;
background-color: #009900;
background-color: #00dd00;
/*background-color: #009900;*/
border-radius: 2px;
}
@ -111,6 +137,11 @@ body {
}
.dialog-close {
display: flex;
justify-content: center;
align-items: center;
line-height: 1.1em;
position: absolute;
top: 0.7em;
right: 0.7em;
@ -153,7 +184,7 @@ body {
.dialog-button {
padding: 0.5em 1em 0.5em 1em;
margin: 0.5em 0.5em 0.5em 0;
/*margin: 0.5em 0.5em 0.5em 0;*/
border: 1px solid #555555;
border-radius: 2px;
@ -176,6 +207,14 @@ body {
border: 1px solid #000000;
}
.dialog-button-group {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: flex-start;
column-gap: 0.5em;
}
.disabled {
border: 1px solid #AAAAAA;
color: #AAAAAA;
@ -186,10 +225,15 @@ body {
}
.logo {
display: block;
display: flex;
flex: 0 1 auto;
height: 100%;
width: auto;
/*display: block;
margin: 0 auto 1em;
max-height: 20vh;
max-width: 100%;
max-width: 100%;*/
}
a,

14
src/index.html Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Test Experiment</title>
<link rel="stylesheet" href="./index.css">
<script src="https://cdn.jsdelivr.net/npm/preloadjs@1.0.1/lib/preloadjs.min.js"></script>
<script type="module" src="test_experiment.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

502
src/test_experiment.js Normal file
View File

@ -0,0 +1,502 @@
/**************
* Gabor Test *
**************/
// import { core, data, sound, util, visual } from '../../psychojs_experimental/psychojsPR519.js';
// import { core, data, sound, util, visual } from "../out/psychojs-2024.1.0.js";
import { core, data, sound, util, visual } from "./index.js";
// import {StimInspector} from 'https://run.pavlovia.org/lgtst/stiminspector/StimInspector.js';
// import {StimInspector} from '../stiminspector/StimInspector.js';
// import {PsyexpReader} from '../psyexpreader/PsyexpReader.js';
const { PsychoJS } = core;
const { TrialHandler } = data;
const { Scheduler } = util;
//some handy aliases as in the psychopy scripts;
const { abs, sin, cos, PI: pi, sqrt } = Math;
const { round } = util;
// store info about the experiment session:
let expName = 'gabor'; // from the Builder filename that created this script
let expInfo = {};
// Start code blocks for 'Before Experiment'
// init psychoJS:
const psychoJS = new PsychoJS({
debug: true
});
window.psychoJS = psychoJS;
window.util = util;
// open window:
psychoJS.openWindow({
fullscr: false,
color: new util.Color("gray"),
units: 'height',
waitBlanking: true
});
// new StimInspector(psychoJS.window, { core, data, sound, util, visual });
// schedule the experiment:
psychoJS.schedule(psychoJS.gui.DlgFromDict({
dictionary: expInfo,
title: expName
}));
const flowScheduler = new Scheduler(psychoJS);
const dialogCancelScheduler = new Scheduler(psychoJS);
psychoJS.scheduleCondition(function() { return (psychoJS.gui.dialogComponent.button === 'OK'); }, flowScheduler, dialogCancelScheduler);
// flowScheduler gets run if the participants presses OK
flowScheduler.add(updateInfo); // add timeStamp
flowScheduler.add(experimentInit);
// flowScheduler.add(instructRoutineBegin());
// flowScheduler.add(instructRoutineEachFrame());
// flowScheduler.add(instructRoutineEnd());
flowScheduler.add(gaborRoutineBegin());
flowScheduler.add(gaborRoutineEachFrame());
flowScheduler.add(gaborRoutineEnd());
flowScheduler.add(quitPsychoJS, '', true);
// quit if user presses Cancel in dialog box:
dialogCancelScheduler.add(quitPsychoJS, '', false);
psychoJS.start({
expName: expName,
expInfo: expInfo,
configURL: "../config.json",
resources: [
{
name: "cool.gif",
path: "./test_resources/cool.gif"
},
{
name: "delorean.gif",
path: "./test_resources/delorean.gif"
}
// {
// name: "007",
// path: "007.jpg"
// },
]
});
psychoJS.experimentLogger.setLevel(core.Logger.ServerLevel.WARNING);
var frameDur;
async function updateInfo() {
expInfo['date'] = util.MonotonicClock.getDateStr(); // add a simple timestamp
expInfo['expName'] = expName;
expInfo['psychopyVersion'] = '2021.3.0';
expInfo['OS'] = window.navigator.platform;
// store frame rate of monitor if we can measure it successfully
expInfo['frameRate'] = psychoJS.window.getActualFrameRate();
if (typeof expInfo['frameRate'] !== 'undefined')
frameDur = 1.0 / Math.round(expInfo['frameRate']);
else
frameDur = 1.0 / 60.0; // couldn't get a reliable measure so guess
// add info from the URL:
util.addInfoFromUrl(expInfo);
return Scheduler.Event.NEXT;
}
var instructClock;
var ready;
var gaborClock;
var gabor;
var stims = [];
window.grating2BlendMode = 'add';
var globalClock;
var routineTimer;
function addWheelListener () {
let v = 1.;
window.addEventListener('wheel', (e) => {
if (!psychoJS) {
return;
}
psychoJS._window._stimsContainer.position.y += e.deltaY * v;
})
}
// var video;
async function experimentInit() {
// Initialize components for Routine "instruct"
instructClock = new util.Clock();
ready = new core.Keyboard({psychoJS: psychoJS, clock: new util.Clock(), waitForStart: true});
psychoJS.window.backgroundImage = "toxen";
// Initialize components for Routine "gabor"
gaborClock = new util.Clock();
stims.push(
// new visual.GratingStim({
// win : psychoJS.window,
// name: 'morph',
// tex: 'sin',
// mask: undefined,
// ori: 0,
// size: [256, 512],
// pos: [0, 0],
// units: "pix",
// depth: 0
// })
new visual.GifStim({
win : psychoJS.window,
name: 'morph',
image: "cool.gif",
mask: undefined,
ori: 0,
size: [512, 512],
pos: [0, 0],
units: "pix",
depth: 0
})
);
window.stims = stims;
// Create some handy timers
globalClock = new util.Clock(); // to track the time since experiment started
routineTimer = new util.CountdownTimer(); // to track time remaining of each (non-slip) routine
addWheelListener();
return Scheduler.Event.NEXT;
}
var t;
var frameN;
var continueRoutine;
var gotValidClick;
var _ready_allKeys;
var instructComponents;
function instructRoutineBegin(snapshot) {
return async function () {
TrialHandler.fromSnapshot(snapshot); // ensure that .thisN vals are up to date
//------Prepare to start Routine 'instruct'-------
t = 0;
instructClock.reset(); // clock
frameN = -1;
continueRoutine = true; // until we're told otherwise
// update component parameters for each repeat
ready.keys = undefined;
ready.rt = undefined;
_ready_allKeys = [];
// keep track of which components have finished
instructComponents = [];
instructComponents.push(ready);
for (const thisComponent of instructComponents)
if ('status' in thisComponent)
thisComponent.status = PsychoJS.Status.NOT_STARTED;
return Scheduler.Event.NEXT;
}
}
function instructRoutineEachFrame() {
return async function () {
//------Loop for each frame of Routine 'instruct'-------
// get current time
t = instructClock.getTime();
frameN = frameN + 1;// number of completed frames (so 0 is the first frame)
// update/draw components on each frame
// *ready* updates
if (t >= 0 && ready.status === PsychoJS.Status.NOT_STARTED) {
// keep track of start time/frame for later
ready.tStart = t; // (not accounting for frame time here)
ready.frameNStart = frameN; // exact frame index
// keyboard checking is just starting
psychoJS.window.callOnFlip(function() { ready.clock.reset(); }); // t=0 on next screen flip
psychoJS.window.callOnFlip(function() { ready.start(); }); // start on screen flip
psychoJS.window.callOnFlip(function() { ready.clearEvents(); });
}
if (ready.status === PsychoJS.Status.STARTED) {
let theseKeys = ready.getKeys({keyList: [], waitRelease: false});
_ready_allKeys = _ready_allKeys.concat(theseKeys);
if (_ready_allKeys.length > 0) {
ready.keys = _ready_allKeys[_ready_allKeys.length - 1].name; // just the last key pressed
ready.rt = _ready_allKeys[_ready_allKeys.length - 1].rt;
// a response ends the routine
continueRoutine = false;
}
}
// check for quit (typically the Esc key)
if (psychoJS.experiment.experimentEnded || psychoJS.eventManager.getKeys({keyList:['escape']}).length > 0) {
return quitPsychoJS('The [Escape] key was pressed. Goodbye!', false);
}
// check if the Routine should terminate
if (!continueRoutine) { // a component has requested a forced-end of Routine
return Scheduler.Event.NEXT;
}
continueRoutine = false; // reverts to True if at least one component still running
for (const thisComponent of instructComponents)
if ('status' in thisComponent && thisComponent.status !== PsychoJS.Status.FINISHED) {
continueRoutine = true;
break;
}
// refresh the screen if continuing
if (continueRoutine) {
return Scheduler.Event.FLIP_REPEAT;
} else {
return Scheduler.Event.NEXT;
}
};
}
function instructRoutineEnd() {
return async function () {
//------Ending Routine 'instruct'-------
for (const thisComponent of instructComponents) {
if (typeof thisComponent.setAutoDraw === 'function') {
thisComponent.setAutoDraw(false);
}
}
ready.stop();
// the Routine "instruct" was not non-slip safe, so reset the non-slip timer
routineTimer.reset();
return Scheduler.Event.NEXT;
};
}
var gaborComponents;
function gaborRoutineBegin(snapshot) {
return async function () {
TrialHandler.fromSnapshot(snapshot); // ensure that .thisN vals are up to date
//------Prepare to start Routine 'instruct'-------
t = 0;
gaborClock.reset(); // clock
frameN = -1;
continueRoutine = true; // until we're told otherwise
// update component parameters for each repeat
ready.keys = undefined;
ready.rt = undefined;
_ready_allKeys = [];
// keep track of which components have finished
gaborComponents = [];
gaborComponents.push(ready);
gaborComponents = [...gaborComponents, ...stims];
for (const thisComponent of gaborComponents)
if ('status' in thisComponent)
thisComponent.status = PsychoJS.Status.NOT_STARTED;
return Scheduler.Event.NEXT;
}
}
var secTimer = 0;
var prevTime = performance.now();
var dynamicDimension = 0;
var newSize = [512, 512];
var newPos = [0, 0];
var sizeTests = [-512, -256.1, -128, 128, 256.6, 512];
var positionTests = [-256, -256.1, 256, 256.1, 0];
var anchorTests = ["left", "topleft", "top", "topright", "right", "bottomright", "bottom", "bottomleft", "center"];
var sizeTestsProgress = 0;
var positionTestsProgress = 0;
var anchorTestsProgress = 0;
var continueAutoTest = true;
window.stopTest = function () {
continueAutoTest = false;
};
window.startTest = function () {
continueAutoTest = true;
};
function gaborRoutineEachFrame() {
return async function () {
//------Loop for each frame of Routine 'gabor'-------
// get current time
t = gaborClock.getTime();
frameN = frameN + 1;// number of completed frames (so 0 is the first frame)
let i;
for (i = 0; i < stims.length; i++) {
if (t >= 0. && stims[i].status === PsychoJS.Status.NOT_STARTED) {
stims[i].tStart = t;
stims[i].frameNStart = frameN;
stims[i].setAutoDraw(true);
}
}
// testing code
secTimer += performance.now() - prevTime;
prevTime = performance.now();
if (secTimer >= 1000 && continueAutoTest)
{
secTimer = 0;
if (sizeTestsProgress < sizeTests.length * 2)
{
i = sizeTestsProgress % sizeTests.length;
newSize[dynamicDimension] = sizeTests[i];
stims[0].setSize(newSize);
sizeTestsProgress++;
console.log("stim size set to", stims[0].getSize());
if (sizeTestsProgress % sizeTests.length === 0)
{
dynamicDimension = (dynamicDimension + 1) % 2;
}
}
else if (sizeTestsProgress < sizeTests.length * 3)
{
i = sizeTestsProgress % sizeTests.length;
newSize[0] = sizeTests[i];
newSize[1] = sizeTests[i];
stims[0].setSize(newSize);
sizeTestsProgress++;
console.log("stim size set to", stims[0].getSize());
}
else if (
sizeTestsProgress >= sizeTests.length * 3 &&
positionTestsProgress < positionTests.length * 2)
{
i = positionTestsProgress % positionTests.length;
newPos[dynamicDimension] = positionTests[i];
stims[0].setPos(newPos);
positionTestsProgress++;
console.log("stim pos set to", stims[0].getPos());
if (positionTestsProgress % positionTests.length === 0)
{
newPos[dynamicDimension] = 0;
dynamicDimension = (dynamicDimension + 1) % 2;
}
}
else if(
sizeTestsProgress >= sizeTests.length * 3 &&
positionTestsProgress >= positionTests.length * 2 &&
anchorTestsProgress < anchorTests.length)
{
i = anchorTestsProgress % anchorTests.length;
stims[0].setAnchor(anchorTests[i]);
anchorTestsProgress++;
console.log("anchor set to", anchorTests[i]);
}
if (
sizeTestsProgress >= sizeTests.length * 3 &&
positionTestsProgress >= positionTests.length * 2 &&
anchorTestsProgress >= anchorTests.length)
{
sizeTestsProgress = 0;
positionTestsProgress = 0;
anchorTestsProgress = 0;
dynamicDimension = 0;
newPos[0] = 0;
newPos[1] = 0;
newSize[0] = 512;
newSize[1] = 512;
console.log("============== full reset ==============");
stims[0].setPos(newPos);
stims[0].setSize(newSize);
stims[0].setAnchor("center");
}
}
// check for quit (typically the Esc key)
if (psychoJS.experiment.experimentEnded || psychoJS.eventManager.getKeys({keyList:['escape']}).length > 0)
{
continueRoutine = false;
}
// check if the Routine should terminate
if (!continueRoutine) { // a component has requested a forced-end of Routine
return Scheduler.Event.NEXT;
}
continueRoutine = false; // reverts to True if at least one component still running
for (const thisComponent of gaborComponents)
if ('status' in thisComponent && thisComponent.status !== PsychoJS.Status.FINISHED) {
continueRoutine = true;
break;
}
// refresh the screen if continuing
if (continueRoutine) {
return Scheduler.Event.FLIP_REPEAT;
} else {
return Scheduler.Event.NEXT;
}
};
}
function gaborRoutineEnd() {
return async function () {
//------Ending Routine 'gabor'-------
for (const thisComponent of gaborComponents) {
if (typeof thisComponent.setAutoDraw === 'function') {
thisComponent.setAutoDraw(false);
}
}
// the Routine "gabor" was not non-slip safe, so reset the non-slip timer
routineTimer.reset();
return Scheduler.Event.NEXT;
};
}
function endLoopIteration(scheduler, snapshot) {
// ------Prepare for next entry------
return async function () {
if (typeof snapshot !== 'undefined') {
// ------Check if user ended loop early------
if (snapshot.finished) {
// Check for and save orphaned data
if (psychoJS.experiment.isEntryEmpty()) {
psychoJS.experiment.nextEntry(snapshot);
}
scheduler.stop();
} else {
const thisTrial = snapshot.getCurrentTrial();
if (typeof thisTrial === 'undefined' || !('isTrials' in thisTrial) || thisTrial.isTrials) {
psychoJS.experiment.nextEntry(snapshot);
}
}
return Scheduler.Event.NEXT;
}
};
}
function importConditions(currentLoop) {
return async function () {
psychoJS.importAttributes(currentLoop.getCurrentTrial());
return Scheduler.Event.NEXT;
};
}
async function quitPsychoJS(message, isCompleted) {
// Check for and save orphaned data
if (psychoJS.experiment.isEntryEmpty()) {
psychoJS.experiment.nextEntry();
}
psychoJS.window.close();
psychoJS.quit({message: message, isCompleted: isCompleted});
return Scheduler.Event.QUIT;
}

BIN
src/test_resources/cool.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

278
src/util/GifParser.js Normal file
View File

@ -0,0 +1,278 @@
/**
* Tool for parsing gif files and decoding it's data to frames.
*
* @author "Matt Way" (https://github.com/matt-way), Nikita Agafonov (https://github.com/lightest)
* @copyright (c) 2015 Matt Way, (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*
* @note Based on https://github.com/matt-way/gifuct-js
*
*/
import GIF from 'js-binary-schema-parser/lib/schemas/gif'
import { parse } from 'js-binary-schema-parser'
import { buildStream } from 'js-binary-schema-parser/lib/parsers/uint8'
/**
* Deinterlace function from https://github.com/shachaf/jsgif
*/
export const deinterlace = (pixels, width) => {
const newPixels = new Array(pixels.length)
const rows = pixels.length / width
const cpRow = function(toRow, fromRow) {
const fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width)
newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels))
}
// See appendix E.
const offsets = [0, 4, 2, 1]
const steps = [8, 8, 4, 2]
var fromRow = 0
for (var pass = 0; pass < 4; pass++) {
for (var toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) {
cpRow(toRow, fromRow)
fromRow++
}
}
return newPixels
}
/**
* javascript port of java LZW decompression
* Original java author url: https://gist.github.com/devunwired/4479231
*/
export const lzw = (minCodeSize, data, pixelCount, memoryBuffer, bufferOffset) => {
const MAX_STACK_SIZE = 4096
const nullCode = -1
const npix = pixelCount
var available,
clear,
code_mask,
code_size,
end_of_information,
in_code,
old_code,
bits,
code,
i,
datum,
data_size,
first,
top,
bi,
pi
// const dstPixels = new Array(pixelCount)
// const prefix = new Array(MAX_STACK_SIZE)
// const suffix = new Array(MAX_STACK_SIZE)
// const pixelStack = new Array(MAX_STACK_SIZE + 1)
const dstPixels = new Uint8Array(memoryBuffer, bufferOffset, pixelCount)
const prefix = new Uint16Array(MAX_STACK_SIZE)
const suffix = new Uint16Array(MAX_STACK_SIZE)
const pixelStack = new Uint8Array(MAX_STACK_SIZE + 1)
// Initialize GIF data stream decoder.
data_size = minCodeSize
clear = 1 << data_size
end_of_information = clear + 1
available = clear + 2
old_code = nullCode
code_size = data_size + 1
code_mask = (1 << code_size) - 1
for (code = 0; code < clear; code++) {
// prefix[code] = 0
suffix[code] = code
}
// Decode GIF pixel stream.
var datum, bits, count, first, top, pi, bi
datum = bits = count = first = top = pi = bi = 0
for (i = 0; i < npix; ) {
if (top === 0) {
if (bits < code_size) {
// get the next byte
datum += data[bi] << bits
bits += 8
bi++
continue
}
// Get the next code.
code = datum & code_mask
datum >>= code_size
bits -= code_size
// Interpret the code
if (code > available || code == end_of_information) {
break
}
if (code == clear) {
// Reset decoder.
code_size = data_size + 1
code_mask = (1 << code_size) - 1
available = clear + 2
old_code = nullCode
continue
}
if (old_code == nullCode) {
pixelStack[top++] = suffix[code]
old_code = code
first = code
continue
}
in_code = code
if (code == available) {
pixelStack[top++] = first
code = old_code
}
while (code > clear) {
pixelStack[top++] = suffix[code]
code = prefix[code]
}
first = suffix[code] & 0xff
pixelStack[top++] = first
// add a new string to the table, but only if space is available
// if not, just continue with current table until a clear code is found
// (deferred clear code implementation as per GIF spec)
if (available < MAX_STACK_SIZE) {
prefix[available] = old_code
suffix[available] = first
available++
if ((available & code_mask) === 0 && available < MAX_STACK_SIZE) {
code_size++
code_mask += available
}
}
old_code = in_code
}
// Pop a pixel off the pixel stack.
top--
dstPixels[pi++] = pixelStack[top]
i++
}
// for (i = pi; i < npix; i++) {
// dstPixels[i] = 0 // clear missing pixels
// }
return dstPixels
}
export const parseGIF = arrayBuffer => {
const byteData = new Uint8Array(arrayBuffer)
return parse(buildStream(byteData), GIF)
}
const generatePatch = image => {
const totalPixels = image.pixels.length
const patchData = new Uint8ClampedArray(totalPixels * 4)
for (var i = 0; i < totalPixels; i++) {
const pos = i * 4
const colorIndex = image.pixels[i]
const color = image.colorTable[colorIndex] || [0, 0, 0]
patchData[pos] = color[0]
patchData[pos + 1] = color[1]
patchData[pos + 2] = color[2]
patchData[pos + 3] = colorIndex !== image.transparentIndex ? 255 : 0
}
return patchData
}
export const decompressFrame = (frame, gct, buildImagePatch, memoryBuffer, memoryOffset) => {
if (!frame.image) {
console.warn('gif frame does not have associated image.')
return
}
const { image } = frame
// get the number of pixels
const totalPixels = image.descriptor.width * image.descriptor.height
// do lzw decompression
var pixels = lzw(image.data.minCodeSize, image.data.blocks, totalPixels, memoryBuffer, memoryOffset)
// deal with interlacing if necessary
if (image.descriptor.lct.interlaced) {
pixels = deinterlace(pixels, image.descriptor.width)
}
const resultImage = {
pixels: pixels,
dims: {
top: frame.image.descriptor.top,
left: frame.image.descriptor.left,
width: frame.image.descriptor.width,
height: frame.image.descriptor.height
}
}
// color table
if (image.descriptor.lct && image.descriptor.lct.exists) {
resultImage.colorTable = image.lct
} else {
resultImage.colorTable = gct
}
// add per frame relevant gce information
if (frame.gce) {
resultImage.delay = (frame.gce.delay || 10) * 10 // convert to ms
resultImage.disposalType = frame.gce.extras.disposal
// transparency
if (frame.gce.extras.transparentColorGiven) {
resultImage.transparentIndex = frame.gce.transparentColorIndex
}
}
// create canvas usable imagedata if desired
if (buildImagePatch) {
resultImage.patch = generatePatch(resultImage)
}
return resultImage
}
export const decompressFrames = (parsedGif, buildImagePatches) => {
// return parsedGif.frames
// .filter(f => f.image)
// .map(f => decompressFrame(f, parsedGif.gct, buildImagePatches))
let totalPixels = 0;
let framesWithData = 0;
let out ;
let i, j = 0;
for (i = 0; i < parsedGif.frames.length; i++) {
if (parsedGif.frames[i].image)
{
totalPixels += parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height;
framesWithData++;
}
}
// const dstPixels = new Uint16Array(totalPixels);
// let frameStart = 0;
// let frameEnd = 0;
const buf = new ArrayBuffer(totalPixels);
let bufOffset = 0;
out = new Array(framesWithData);
for (i = 0; i < parsedGif.frames.length; i++) {
if (parsedGif.frames[i].image)
{
out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches, buf, bufOffset);
bufOffset += parsedGif.frames[i].image.descriptor.width * parsedGif.frames[i].image.descriptor.height;
// out[j] = decompressFrame(parsedGif.frames[i], parsedGif.gct, buildImagePatches, prefix, suffix, pixelStack, dstPixels, frameStart, frameEnd);
j++;
}
}
return out;
}

View File

@ -117,9 +117,12 @@ export class Scheduler
* Start this scheduler.
*
* <p>Note: tasks are run after each animation frame.</p>
*
* @return {Promise<void>} a promise resolved when the scheduler stops, e.g. when the experiments finishes
*/
start()
{
let shedulerResolve;
const self = this;
const update = async (timestamp) =>
{
@ -127,6 +130,7 @@ export class Scheduler
if (self._stopAtNextUpdate)
{
self._status = Scheduler.Status.STOPPED;
shedulerResolve();
return;
}
@ -137,6 +141,7 @@ export class Scheduler
if (state === Scheduler.Event.QUIT)
{
self._status = Scheduler.Status.STOPPED;
shedulerResolve();
return;
}
@ -155,6 +160,12 @@ export class Scheduler
// start the animation:
requestAnimationFrame(update);
// return a promise resolved when the scheduler is stopped:
return new Promise((resolve, _) =>
{
shedulerResolve = resolve;
});
}
/**

View File

@ -362,6 +362,24 @@ export function shuffle(array, randomNumberGenerator = undefined, startIndex = u
return array;
}
/**
* linspace
*
* @name module:util.linspace
* @function
* @public
* @param {Object[]} startValue, stopValue, cardinality
* @return {Object[]} an array from startValue to stopValue with cardinality steps
*/
export function linspace(startValue, stopValue, cardinality) {
var arr = [];
var step = (stopValue - startValue) / (cardinality - 1);
for (var i = 0; i < cardinality; i++) {
arr.push(startValue + (step * i));
}
return arr;
}
/**
* Pick a random value from an array, uses `util.shuffle` to shuffle the array and returns the last value.
*
@ -629,6 +647,11 @@ export function toString(object)
return object.toString();
}
if (typeof object === "function")
{
return `<function ${object.name}>`;
}
try
{
const symbolReplacer = (key, value) =>
@ -1455,6 +1478,47 @@ export function loadCss(cssId, cssPath)
}
}
/**
* Whether the user device has a touchscreen, e.g. it is a mobile phone or tablet.
*
* @return {boolean} true if the user device has a touchscreen.
* @note the code below is directly adapted from MDN
*/
export function hasTouchScreen()
{
let hasTouchScreen = false;
if ("maxTouchPoints" in navigator)
{
hasTouchScreen = navigator.maxTouchPoints > 0;
}
else if ("msMaxTouchPoints" in navigator)
{
hasTouchScreen = navigator.msMaxTouchPoints > 0;
}
else
{
const mQ = matchMedia?.("(pointer:coarse)");
if (mQ?.media === "(pointer:coarse)")
{
hasTouchScreen = !!mQ.matches;
}
else if ("orientation" in window)
{
hasTouchScreen = true;
}
else
{
const UA = navigator.userAgent;
hasTouchScreen =
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA);
}
}
return hasTouchScreen;
}
/**
* Enum that stores possible text directions.
* Note that Arabic is the same as RTL but added here to support PsychoPy's

441
src/visual/AnimatedGIF.js Normal file
View File

@ -0,0 +1,441 @@
/**
* Animated gif sprite.
*
* @author Nikita Agafonov (https://github.com/lightest), Matt Karl (https://github.com/bigtimebuddy)
* @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*
* @note Based on https://github.com/pixijs/gif and heavily modified.
*
*/
import * as PIXI from "pixi.js-legacy";
/**
* Runtime object to play animated GIFs. This object is similar to an AnimatedSprite.
* It support playback (seek, play, stop) as well as animation speed and looping.
*/
class AnimatedGIF extends PIXI.Sprite
{
/**
* Default options for all AnimatedGIF objects.
* @property {PIXI.SCALE_MODES} [scaleMode=PIXI.SCALE_MODES.LINEAR] - Scale mode to use for the texture.
* @property {boolean} [loop=true] - To enable looping.
* @property {number} [animationSpeed=1] - Speed of the animation.
* @property {boolean} [autoUpdate=true] - Set to `false` to manage updates yourself.
* @property {boolean} [autoPlay=true] - To start playing right away.
* @property {Function} [onComplete=null] - The completed callback, optional.
* @property {Function} [onLoop=null] - The loop callback, optional.
* @property {Function} [onFrameChange=null] - The frame callback, optional.
* @property {number} [fps=PIXI.Ticker.shared.FPS] - Default FPS.
*/
static defaultOptions = {
scaleMode: PIXI.SCALE_MODES.LINEAR,
fps: PIXI.Ticker.shared.FPS,
loop: true,
animationSpeed: 1,
autoPlay: true,
autoUpdate: true,
onComplete: null,
onFrameChange: null,
onLoop: null
};
/**
* @param frames - Data of the GIF image.
* @param options - Options for the AnimatedGIF
*/
constructor(decompressedFrames, options)
{
// Get the options, apply defaults
const { scaleMode, width, height, ...rest } = Object.assign({},
AnimatedGIF.defaultOptions,
options
);
super(new PIXI.Texture(PIXI.BaseTexture.fromBuffer(new Uint8Array(width * height * 4), width, height, options)));
this._name = options.name;
this._useFullFrames = false;
this._decompressedFrameData = decompressedFrames;
this._origDims = { width, height };
let i, j, time = 0;
this._frameTimings = new Array(decompressedFrames.length);
for (i = 0; i < decompressedFrames.length; i++)
{
this._frameTimings[i] =
{
start: time,
end: time + decompressedFrames[i].delay
};
time += decompressedFrames[i].delay;
}
this.duration = this._frameTimings[decompressedFrames.length - 1].end;
this._fullPixelData = [];
if (options.fullFrames !== undefined && options.fullFrames.length > 0)
{
this._fullPixelData = options.fullFrames;
this._useFullFrames = true;
}
this._playing = false;
this._currentTime = 0;
this._isConnectedToTicker = false;
Object.assign(this, rest);
// Draw the first frame
this.currentFrame = 0;
this._prevRenderedFrameIdx = -1;
if (this.autoPlay)
{
this.play();
}
}
static updatePixelsForOneFrame (decompressedFrameData, pixelBuffer, gifWidth)
{
let i = 0;
let patchRow = 0, patchCol = 0;
let offset = 0;
let colorData;
if (decompressedFrameData.pixels.length === pixelBuffer.length / 4)
{
// Not all GIF files are perfectly optimized
// and instead of having tiny patch of pixels that actually changed from previous frame
// they would have a full next frame.
// Knowing that, we can go faster by skipping math needed to determine where to put new pixels
// and just place them 1 to 1 over existing frame (probably internal browser optimizations also kick in).
// For large amounts of gifs running simultaniously this results in 58+FPS vs 15-25+FPS for "else" case.
for (i = 0; i < decompressedFrameData.pixels.length; i++) {
if (decompressedFrameData.pixels[i] !== decompressedFrameData.transparentIndex) {
colorData = decompressedFrameData.colorTable[decompressedFrameData.pixels[i]];
offset = i * 4;
pixelBuffer[offset] = colorData[0];
pixelBuffer[offset + 1] = colorData[1];
pixelBuffer[offset + 2] = colorData[2];
pixelBuffer[offset + 3] = 255;
}
}
}
else
{
for (i = 0; i < decompressedFrameData.pixels.length; i++) {
if (decompressedFrameData.pixels[i] !== decompressedFrameData.transparentIndex) {
colorData = decompressedFrameData.colorTable[decompressedFrameData.pixels[i]];
patchRow = (i / decompressedFrameData.dims.width) | 0;
patchCol = i % decompressedFrameData.dims.width;
offset = (gifWidth * (decompressedFrameData.dims.top + patchRow) + decompressedFrameData.dims.left + patchCol) * 4;
pixelBuffer[offset] = colorData[0];
pixelBuffer[offset + 1] = colorData[1];
pixelBuffer[offset + 2] = colorData[2];
pixelBuffer[offset + 3] = 255;
}
}
}
}
static computeFullFrames (decompressedFrames, gifWidth, gifHeight)
{
let t = performance.now();
let i, j;
let patchRow = 0, patchCol = 0;
let offset = 0;
let colorData;
let pixelData = new Uint8Array(gifWidth * gifHeight * 4);
let fullPixelData = new Uint8Array(gifWidth * gifHeight * 4 * decompressedFrames.length);
for (i = 0; i < decompressedFrames.length; i++)
{
AnimatedGIF.updatePixelsForOneFrame(decompressedFrames[i], pixelData, gifWidth);
fullPixelData.set(pixelData, pixelData.length * i);
}
console.log("full frames construction time", performance.now() - t);
return fullPixelData;
}
_constructNthFullFrame (desiredFrameIdx, prevRenderedFrameIdx, decompressedFrames, pixelBuffer)
{
let t = performance.now();
// saving to variable instead of referencing object in the loop wins up to 5ms!
// (at the moment of development observed on Win10, Chrome 103.0.5060.114 (Official Build) (64-bit))
const gifWidth = this._origDims.width;
let i;
for (i = prevRenderedFrameIdx + 1; i <= desiredFrameIdx; i++)
{
// this._updatePixelsForOneFrame(decompressedFrames[i], pixelBuffer);
AnimatedGIF.updatePixelsForOneFrame(decompressedFrames[i], pixelBuffer, gifWidth)
}
// console.log("constructed frames from", prevRenderedFrameIdx, "to", desiredFrameIdx, "(", desiredFrameIdx - prevRenderedFrameIdx, ")", performance.now() - t);
}
/** Stops the animation. */
stop()
{
if (!this._playing)
{
return;
}
this._playing = false;
if (this._autoUpdate && this._isConnectedToTicker)
{
PIXI.Ticker.shared.remove(this.update, this);
this._isConnectedToTicker = false;
}
}
/** Plays the animation. */
play()
{
if (this._playing)
{
return;
}
this._playing = true;
if (this._autoUpdate && !this._isConnectedToTicker)
{
PIXI.Ticker.shared.add(this.update, this, PIXI.UPDATE_PRIORITY.HIGH);
this._isConnectedToTicker = true;
}
// If were on the last frame and stopped, play should resume from beginning
if (!this.loop && this.currentFrame === this._decompressedFrameData.length - 1)
{
this._currentTime = 0;
}
}
/**
* Get the current progress of the animation from 0 to 1.
* @readonly
*/
get progress()
{
return this._currentTime / this.duration;
}
/** `true` if the current animation is playing */
get playing()
{
return this._playing;
}
/**
* Updates the object transform for rendering. You only need to call this
* if the `autoUpdate` property is set to `false`.
*
* @param deltaTime - Time since last tick.
*/
update(deltaTime)
{
if (!this._playing)
{
return;
}
const elapsed = this.animationSpeed * deltaTime / PIXI.settings.TARGET_FPMS;
const currentTime = this._currentTime + elapsed;
const localTime = currentTime % this.duration;
const localFrame = this._frameTimings.findIndex((ft) =>
ft.start <= localTime && ft.end > localTime);
if (this._prevRenderedFrameIdx > localFrame)
{
this._prevRenderedFrameIdx = -1;
}
if (currentTime >= this.duration)
{
if (this.loop)
{
this._currentTime = localTime;
this.updateFrameIndex(localFrame);
if (typeof this.onLoop === "function")
{
this.onLoop();
}
}
else
{
this._currentTime = this.duration;
this.updateFrameIndex(this._decompressedFrameData.length - 1);
if (typeof this.onComplete === "function")
{
this.onComplete();
}
this.stop();
}
}
else
{
this._currentTime = localTime;
this.updateFrameIndex(localFrame);
}
}
/**
* Redraw the current frame, is necessary for the animation to work when
*/
updateFrame()
{
// if (!this.dirty)
// {
// return;
// }
if (this._prevRenderedFrameIdx === this._currentFrame)
{
return;
}
// Update the current frame
if (this._useFullFrames)
{
this.texture.baseTexture.resource.data = new Uint8Array
(
this._fullPixelData.buffer, this._currentFrame * this._origDims.width * this._origDims.height * 4,
this._origDims.width * this._origDims.height * 4
);
}
else
{
// this._updatePixelsForOneFrame(this._decompressedFrameData[this._currentFrame], this.texture.baseTexture.resource.data);
this._constructNthFullFrame(this._currentFrame, this._prevRenderedFrameIdx, this._decompressedFrameData, this.texture.baseTexture.resource.data);
}
this.texture.update();
// Mark as clean
// this.dirty = false;
this._prevRenderedFrameIdx = this._currentFrame;
}
/**
* Renders the object using the WebGL renderer
*
* @param {PIXI.Renderer} renderer - The renderer
* @private
*/
_render(renderer)
{
let t = performance.now();
this.updateFrame();
// console.log("t2", this._name, performance.now() - t);
super._render(renderer);
}
/**
* Renders the object using the WebGL renderer
*
* @param {PIXI.CanvasRenderer} renderer - The renderer
* @private
*/
_renderCanvas(renderer)
{
this.updateFrame();
super._renderCanvas(renderer);
}
/**
* Whether to use PIXI.Ticker.shared to auto update animation time.
* @default true
*/
get autoUpdate()
{
return this._autoUpdate;
}
set autoUpdate(value)
{
if (value !== this._autoUpdate)
{
this._autoUpdate = value;
if (!this._autoUpdate && this._isConnectedToTicker)
{
PIXI.Ticker.shared.remove(this.update, this);
this._isConnectedToTicker = false;
}
else if (this._autoUpdate && !this._isConnectedToTicker && this._playing)
{
PIXI.Ticker.shared.add(this.update, this);
this._isConnectedToTicker = true;
}
}
}
/** Set the current frame number */
get currentFrame()
{
return this._currentFrame;
}
set currentFrame(value)
{
this.updateFrameIndex(value);
this._currentTime = this._frameTimings[value].start;
}
/** Internally handle updating the frame index */
updateFrameIndex(value)
{
if (value < 0 || value >= this._decompressedFrameData.length)
{
throw new Error(`Frame index out of range, expecting 0 to ${this.totalFrames}, got ${value}`);
}
if (this._currentFrame !== value)
{
this._currentFrame = value;
// this.dirty = true;
if (typeof this.onFrameChange === "function")
{
this.onFrameChange(value);
}
}
}
/**
* Get the total number of frame in the GIF.
*/
get totalFrames()
{
return this._decompressedFrameData.length;
}
/** Destroy and don't use after this. */
destroy()
{
this.stop();
super.destroy(true);
this._decompressedFrameData = null;
this._fullPixelData = null;
this.onComplete = null;
this.onFrameChange = null;
this.onLoop = null;
}
/**
* Cloning the animation is a useful way to create a duplicate animation.
* This maintains all the properties of the original animation but allows
* you to control playback independent of the original animation.
* If you want to create a simple copy, and not control independently,
* then you can simply create a new Sprite, e.g. `const sprite = new Sprite(animation.texture)`.
*/
clone()
{
return new AnimatedGIF([...this._decompressedFrameData], {
autoUpdate: this._autoUpdate,
loop: this.loop,
autoPlay: this.autoPlay,
scaleMode: this.texture.baseTexture.scaleMode,
animationSpeed: this.animationSpeed,
width: this._origDims.width,
height: this._origDims.height,
onComplete: this.onComplete,
onFrameChange: this.onFrameChange,
onLoop: this.onLoop,
});
}
}
export { AnimatedGIF };

View File

@ -9,6 +9,7 @@
import { Mouse } from "../core/Mouse.js";
import { TextBox } from "./TextBox.js";
import * as util from "../util/Util";
/**
* <p>ButtonStim visual stimulus.</p>
@ -32,6 +33,7 @@ export class ButtonStim extends TextBox
* @param {Color} [options.borderColor= Color("white")] the border color
* @param {Color} [options.borderWidth= 0] the border width
* @param {number} [options.opacity= 1.0] - the opacity
* @param {number} [options.depth= 0] - the depth (i.e. the z order)
* @param {number} [options.letterHeight= undefined] - the height of the text
* @param {boolean} [options.bold= true] - whether or not the text is bold
* @param {boolean} [options.italic= false] - whether or not the text is italic
@ -55,12 +57,15 @@ export class ButtonStim extends TextBox
borderColor,
borderWidth = 0,
opacity,
depth,
letterHeight,
bold = true,
italic,
autoDraw,
autoLog,
draggable
draggable,
boxFn,
multiline
} = {},
)
{
@ -79,13 +84,16 @@ export class ButtonStim extends TextBox
borderColor,
borderWidth,
opacity,
depth,
letterHeight,
multiline,
bold,
italic,
alignment: "center",
autoDraw,
autoLog,
draggable
draggable,
boxFn
});
this.psychoJS.logger.debug("create a new Button with name: ", name);
@ -115,7 +123,7 @@ export class ButtonStim extends TextBox
if (this._autoLog)
{
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`);
}
}

515
src/visual/GifStim.js Normal file
View File

@ -0,0 +1,515 @@
/**
* Gif Stimulus.
*
* @author Nikita Agafonov
* @version 2022.2.0
* @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
import * as PIXI from "pixi.js-legacy";
import { Color } from "../util/Color.js";
import { ColorMixin } from "../util/ColorMixin.js";
import { to_pixiPoint } from "../util/Pixi.js";
import * as util from "../util/Util.js";
import { VisualStim } from "./VisualStim.js";
import {Camera} from "../hardware";
// import { parseGIF, decompressFrames } from "gifuct-js";
import { AnimatedGIF } from "./AnimatedGIF.js";
import { parseGIF, decompressFrames } from "../util/GifParser.js";
/**
* Gif Stimulus.
*
* @name module:visual.GifStim
* @class
* @extends VisualStim
* @mixes ColorMixin
* @param {Object} options
* @param {String} options.name - the name used when logging messages from this stimulus
* @param {Window} options.win - the associated Window
* @param {boolean} options.precomputeFrames - compute full frames of the GIF and store them. Setting this to true will take the load off the CPU
* @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image
* @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask
* but GIF will take longer to load and occupy more memory space. In case when there's not enough CPU peformance (e.g. due to large amount of GIFs
* playing simultaneously or heavy load elsewhere in experiment) and you don't care much about app memory usage, use this flag to easily gain more performance.
* @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices)
* @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus
* @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position
* @param {number} [options.ori= 0.0] - the orientation (in degrees)
* @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified)
* @param {Color} [options.color= 'white'] the background color
* @param {number} [options.opacity= 1.0] - the opacity
* @param {number} [options.contrast= 1.0] - the contrast
* @param {number} [options.depth= 0] - the depth (i.e. the z order)
* @param {number} [options.texRes= 128] - the resolution of the text
* @param {boolean} [options.loop= true] - whether or not to loop the animation
* @param {boolean} [options.autoPlay= true] - whether or not to autoPlay the animation
* @param {boolean} [options.animationSpeed= 1] - animation speed, works as multiplyer e.g. 1 - normal speed, 0.5 - half speed, 2 - twice as fast etc.
* @param {boolean} [options.interpolate= false] - whether or not the image is interpolated
* @param {boolean} [options.flipHoriz= false] - whether or not to flip horizontally
* @param {boolean} [options.flipVert= false] - whether or not to flip vertically
* @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
* @param {boolean} [options.autoLog= false] - whether or not to log
*/
export class GifStim extends util.mix(VisualStim).with(ColorMixin)
{
constructor({
name,
win,
image,
mask,
precomputeFrames,
pos,
units,
ori,
size,
color,
opacity,
contrast,
texRes,
depth,
interpolate,
loop,
autoPlay,
animationSpeed,
flipHoriz,
flipVert,
autoDraw,
autoLog
} = {})
{
super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog });
this._resource = undefined;
this._addAttribute("precomputeFrames", precomputeFrames, false);
this._addAttribute("image", image);
this._addAttribute("mask", mask);
this._addAttribute("color", color, "white", this._onChange(true, false));
this._addAttribute("contrast", contrast, 1.0, this._onChange(true, false));
this._addAttribute("texRes", texRes, 128, this._onChange(true, false));
this._addAttribute("interpolate", interpolate, false);
this._addAttribute("flipHoriz", flipHoriz, false, this._onChange(false, false));
this._addAttribute("flipVert", flipVert, false, this._onChange(false, false));
this._addAttribute("loop", loop, true);
this._addAttribute("autoPlay", autoPlay, true);
this._addAttribute("animationSpeed", animationSpeed, 1);
// estimate the bounding box:
this._estimateBoundingBox();
if (this._autoLog)
{
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
}
}
/**
* Getter for the playing property.
*
* @name module:visual.GifStim#isPlaying
* @public
*/
get isPlaying ()
{
if (this._pixi)
{
return this._pixi.playing;
}
return false;
}
/**
* Getter for the duration property. Shows animation duration time in milliseconds.
*
* @name module:visual.GifStim#duration
* @public
*/
get duration ()
{
if (this._pixi)
{
return this._pixi.duration;
}
}
/**
* Starts GIF playback.
*
* @name module:visual.GifStim#play
* @public
*/
play ()
{
if (this._pixi)
{
this._pixi.play();
}
}
/**
* Pauses GIF playback.
*
* @name module:visual.GifStim#pause
* @public
*/
pause ()
{
if (this._pixi)
{
this._pixi.stop();
}
}
/**
* Set wether or not to loop the animation.
*
* @name module:visual.GifStim#setLoop
* @public
* @param {boolean} [loop=true] - flag value
* @param {boolean} [log=false] - whether or not to log.
*/
setLoop (loop, log = false)
{
this._setAttribute("loop", loop, log);
if (this._pixi)
{
this._pixi.loop = loop;
}
}
/**
* Set wether or not to autoplay the animation.
*
* @name module:visual.GifStim#setAutoPlay
* @public
* @param {boolean} [autoPlay=true] - flag value
* @param {boolean} [log=false] - whether or not to log.
*/
setAutoPlay (autoPlay, log = false)
{
this._setAttribute("autoPlay", autoPlay, log);
if (this._pixi)
{
this._pixi.autoPlay = autoPlay;
}
}
/**
* Set animation speed of the animation.
*
* @name module:visual.GifStim#setAnimationSpeed
* @public
* @param {boolean} [animationSpeed=1] - multiplyer of the animation speed e.g. 1 - normal, 0.5 - half speed, 2 - twice as fast.
* @param {boolean} [log=false] - whether or not to log.
*/
setAnimationSpeed (animationSpeed = 1, log = false)
{
this._setAttribute("animationSpeed", animationSpeed, log);
if (this._pixi)
{
this._pixi.animationSpeed = animationSpeed;
}
}
/**
* Setter for the image attribute.
*
* @name module:visual.GifStim#setImage
* @public
* @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image
* @param {boolean} [log= false] - whether or not to log
*/
setImage(image, log = false)
{
const response = {
origin: "GifStim.setImage",
context: "when setting the image of GifStim: " + this._name,
};
try
{
// image is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem
if (typeof image === "undefined")
{
this.psychoJS.logger.warn("setting the image of GifStim: " + this._name + " with argument: undefined.");
this.psychoJS.logger.debug("set the image of GifStim: " + this._name + " as: undefined");
}
else if (typeof image === "string")
{
// image is a string: it should be the name of a resource, which we load
const fullRD = this.psychoJS.serverManager.getFullResourceData(image);
console.log("gif resource", fullRD);
if (fullRD.cachedData === undefined)
{
// How GIF works: http://www.matthewflickinger.com/lab/whatsinagif/animation_and_transparency.asp
let t0 = performance.now();
let parsedGif = parseGIF(fullRD.data);
let pt = performance.now() - t0;
let t2 = performance.now();
let decompressedFrames = decompressFrames(parsedGif, false);
let dect = performance.now() - t2;
let fullFrames;
if (this._precomputeFrames)
{
fullFrames = AnimatedGIF.computeFullFrames(decompressedFrames, parsedGif.lsd.width, parsedGif.lsd.height);
}
this._resource = { parsedGif, decompressedFrames, fullFrames };
this.psychoJS.serverManager.cacheResourceData(image, this._resource);
console.log(`animated gif "${this._name}",`, "parse=", pt, "decompress=", dect);
}
else
{
this._resource = fullRD.cachedData;
}
// this.psychoJS.logger.debug(`set resource of GifStim: ${this._name} as ArrayBuffer(${this._resource.length})`);
const hasChanged = this._setAttribute("image", image, log);
if (hasChanged)
{
this._onChange(true, true)();
}
}
}
catch (error)
{
throw Object.assign(response, { error });
}
}
/**
* Setter for the mask attribute.
*
* @name module:visual.GifStim#setMask
* @public
* @param {HTMLImageElement | string} mask - the name of the mask resource or HTMLImageElement corresponding to the mask
* @param {boolean} [log= false] - whether of not to log
*/
setMask(mask, log = false)
{
const response = {
origin: "GifStim.setMask",
context: "when setting the mask of GifStim: " + this._name,
};
try
{
// mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem
if (typeof mask === "undefined")
{
this.psychoJS.logger.warn("setting the mask of GifStim: " + this._name + " with argument: undefined.");
this.psychoJS.logger.debug("set the mask of GifStim: " + this._name + " as: undefined");
}
else
{
// mask is a string: it should be the name of a resource, which we load
if (typeof mask === "string")
{
mask = this.psychoJS.serverManager.getResource(mask);
}
// mask should now be an actual HTMLImageElement: we raise an error if it is not
if (!(mask instanceof HTMLImageElement))
{
throw "the argument: " + mask.toString() + ' is not an image" }';
}
this.psychoJS.logger.debug("set the mask of GifStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height);
}
this._setAttribute("mask", mask, log);
this._onChange(true, false)();
}
catch (error)
{
throw Object.assign(response, { error });
}
}
/**
* Whether to interpolate (linearly) the texture in the stimulus.
*
* @name module:visual.GifStim#setInterpolate
* @public
* @param {boolean} interpolate - interpolate or not.
* @param {boolean} [log=false] - whether or not to log
*/
setInterpolate (interpolate = false, log = false)
{
this._setAttribute("interpolate", interpolate, log);
if (this._pixi instanceof PIXI.Sprite) {
this._pixi.texture.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST;
this._pixi.texture.baseTexture.update();
}
}
/**
* Setter for the size attribute.
*
* @param {undefined | null | number | number[]} size - the stimulus size
* @param {boolean} [log= false] - whether of not to log
*/
setSize(size, log = false)
{
// size is either undefined, null, or a tuple of numbers:
if (typeof size !== "undefined" && size !== null)
{
size = util.toNumerical(size);
if (!Array.isArray(size))
{
size = [size, size];
}
}
this._setAttribute("size", size, log);
if (this._pixi)
{
const size_px = util.to_px(size, this.units, this.win);
const scaleX = size_px[0] / this._pixi.texture.width;
const scaleY = size_px[1] / this._pixi.texture.height;
this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX;
this._pixi.scale.y = this.flipVert ? scaleY : -scaleY;
}
}
/**
* Estimate the bounding box.
*
* @name module:visual.GifStim#_estimateBoundingBox
* @function
* @override
* @protected
*/
_estimateBoundingBox()
{
const size = this._getDisplaySize();
if (typeof size !== "undefined")
{
this._boundingBox = new PIXI.Rectangle(
this._pos[0] - size[0] / 2,
this._pos[1] - size[1] / 2,
size[0],
size[1],
);
}
// TODO take the orientation into account
}
/**
* Update the stimulus, if necessary.
*
* @name module:visual.GifStim#_updateIfNeeded
* @private
*/
_updateIfNeeded()
{
if (!this._needUpdate)
{
return;
}
this._needUpdate = false;
// update the PIXI representation, if need be:
if (this._needPixiUpdate)
{
this._needPixiUpdate = false;
if (typeof this._pixi !== "undefined")
{
this._pixi.destroy(true);
}
this._pixi = undefined;
// no image to draw: return immediately
if (typeof this._resource === "undefined")
{
return;
}
const gifOpts =
{
name: this._name,
width: this._resource.parsedGif.lsd.width,
height: this._resource.parsedGif.lsd.height,
fullFrames: this._resource.fullFrames,
scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST,
loop: this._loop,
autoPlay: this._autoPlay,
animationSpeed: this._animationSpeed
};
let t = performance.now();
this._pixi = new AnimatedGIF(this._resource.decompressedFrames, gifOpts);
console.log(`animatedGif "${this._name}" instancing:`, performance.now() - t);
// add a mask if need be:
if (typeof this._mask !== "undefined")
{
// Building new PIXI.BaseTexture each time we create a mask, to avoid PIXI's caching and use a unique resource.
this._pixi.mask = PIXI.Sprite.from(new PIXI.Texture(new PIXI.BaseTexture(this._mask)));
// a 0.5, 0.5 anchor is required for the mask to be aligned with the image
this._pixi.mask.anchor.x = 0.5;
this._pixi.mask.anchor.y = 0.5;
this._pixi.addChild(this._pixi.mask);
}
// since _texture.width may not be immediately available but the rest of the code needs its value
// we arrange for repeated calls to _updateIfNeeded until we have a width:
if (this._pixi.texture.width === 0)
{
this._needUpdate = true;
this._needPixiUpdate = true;
return;
}
}
this._pixi.zIndex = -this._depth;
this._pixi.alpha = this.opacity;
// set the scale:
const displaySize = this._getDisplaySize();
const size_px = util.to_px(displaySize, this.units, this.win);
const scaleX = size_px[0] / this._pixi.texture.width;
const scaleY = size_px[1] / this._pixi.texture.height;
this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX;
this._pixi.scale.y = this.flipVert ? scaleY : -scaleY;
// set the position, rotation, and anchor (image centered on pos):
this._pixi.position = to_pixiPoint(this.pos, this.units, this.win);
this._pixi.rotation = -this.ori * Math.PI / 180;
this._pixi.anchor.x = 0.5;
this._pixi.anchor.y = 0.5;
// re-estimate the bounding box, as the texture's width may now be available:
this._estimateBoundingBox();
}
/**
* Get the size of the display image, which is either that of the GifStim or that of the image
* it contains.
*
* @name module:visual.GifStim#_getDisplaySize
* @private
* @return {number[]} the size of the displayed image
*/
_getDisplaySize()
{
let displaySize = this.size;
if (this._pixi && typeof displaySize === "undefined")
{
// use the size of the texture, if we have access to it:
if (typeof this._pixi.texture !== "undefined" && this._pixi.texture.width > 0)
{
const textureSize = [this._pixi.texture.width, this._pixi.texture.height];
displaySize = util.to_unit(textureSize, "pix", this.win, this.units);
}
}
return displaySize;
}
}

View File

@ -47,11 +47,39 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
* @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
* @param {boolean} [options.autoLog= false] - whether or not to log
* @param {boolean} [options.draggable= false] - whether or not to make stim draggable with mouse/touch/other pointer device
* @param {ImageStim.AspectRatioStrategy} [options.aspectRatio= ImageStim.AspectRatioStrategy.VARIABLE] - the aspect ratio handling strategy
* @param {number} [options.blurVal= 0] - the blur value. Goes 0 to as hish as you like. 0 is no blur.
*/
constructor({ name, win, image, mask, pos, anchor, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog, draggable } = {})
constructor({
name,
win,
image,
mask,
pos,
anchor,
units,
ori,
size,
color,
opacity,
contrast,
texRes,
depth,
interpolate,
flipHoriz,
flipVert,
autoDraw,
autoLog,
aspectRatio,
draggable,
blurVal
} = {})
{
super({ name, win, units, ori, opacity, depth, pos, anchor, size, autoDraw, autoLog, draggable });
// Holds an instance of PIXI blur filter. Used if blur value is passed.
this._blurFilter = undefined;
this._addAttribute(
"image",
image,
@ -95,6 +123,17 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
false,
this._onChange(false, false),
);
this._addAttribute(
"aspectRatio",
aspectRatio,
ImageStim.AspectRatioStrategy.VARIABLE,
this._onChange(true, true),
);
this._addAttribute(
"blurVal",
blurVal,
0
);
// estimate the bounding box:
this._estimateBoundingBox();
@ -235,6 +274,33 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
}
}
setBlurVal (blurVal = 0, log = false)
{
this._setAttribute("blurVal", blurVal, log);
if (this._pixi instanceof PIXI.Sprite)
{
if (this._blurFilter === undefined)
{
this._blurFilter = new PIXI.filters.BlurFilter();
this._blurFilter.blur = blurVal;
}
else
{
this._blurFilter.blur = blurVal;
}
// this._pixi might get destroyed and recreated again with no filters.
if (this._pixi.filters instanceof Array && this._pixi.filters.indexOf(this._blurFilter) === -1)
{
this._pixi.filters.push(this._blurFilter);
}
else
{
this._pixi.filters = [this._blurFilter];
}
}
}
/**
* Estimate the bounding box.
*
@ -277,6 +343,7 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
if (typeof this._pixi !== "undefined")
{
this._pixi.filters = null;
this._pixi.destroy(true);
}
this._pixi = undefined;
@ -310,7 +377,18 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
this._texture = new PIXI.Texture(new PIXI.BaseTexture(this._image, texOpts));
}
this._pixi = PIXI.Sprite.from(this._texture);
if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING)
{
const [width_px, _] = util.to_px([this.size[0], 0], this.units, this.win);
this._pixi = PIXI.TilingSprite.from(this._texture, 1, 1);
this._pixi.width = width_px;
this._pixi.height = this._texture.height;
}
else
{
this._pixi = PIXI.Sprite.from(this._texture);
}
// add a mask if need be:
if (typeof this._mask !== "undefined")
@ -350,8 +428,24 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
// set the scale:
const displaySize = this._getDisplaySize();
const size_px = util.to_px(displaySize, this.units, this.win);
const scaleX = size_px[0] / this._texture.width;
const scaleY = size_px[1] / this._texture.height;
let scaleX = size_px[0] / this._texture.width;
let scaleY = size_px[1] / this._texture.height;
if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH)
{
scaleY = scaleX;
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT)
{
scaleX = scaleY;
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING)
{
scaleX = 1.0;
scaleY = 1.0;
}
// note: this calls VisualStim.setAnchor, which properly sets the PixiJS anchor
// from the PsychoPy text format
this.anchor = this._anchor;
this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX;
this._pixi.scale.y = this.flipVert ? scaleY : -scaleY;
@ -360,6 +454,11 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
this._pixi.position = to_pixiPoint(this.pos, this.units, this.win);
this._pixi.rotation = -this.ori * Math.PI / 180;
if (this._blurVal > 0)
{
this.setBlurVal(this._blurVal);
}
// re-estimate the bounding box, as the texture's width may now be available:
this._estimateBoundingBox();
}
@ -384,7 +483,47 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
displaySize = util.to_unit(textureSize, "pix", this.win, this.units);
}
}
else
{
if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_WIDTH)
{
// use the size of the texture, if we have access to it:
if (typeof this._texture !== "undefined" && this._texture.width > 0)
{
displaySize = [displaySize[0], displaySize[0] * this._texture.height / this._texture.width];
}
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.FIT_TO_HEIGHT)
{
// use the size of the texture, if we have access to it:
if (typeof this._texture !== "undefined" && this._texture.width > 0)
{
displaySize = [displaySize[1] * this._texture.width / this._texture.height, displaySize[1]];
}
}
else if (this.aspectRatio === ImageStim.AspectRatioStrategy.HORIZONTAL_TILING)
{
// use the size of the texture, if we have access to it:
if (typeof this._texture !== "undefined" && this._texture.width > 0)
{
displaySize = [displaySize[0], this._texture.height];
}
}
}
return displaySize;
}
}
/**
* ImageStim Aspect Ratio Strategy.
*
* @enum {Symbol}
* @readonly
*/
ImageStim.AspectRatioStrategy = {
FIT_TO_WIDTH: Symbol.for("FIT_TO_WIDTH"),
HORIZONTAL_TILING: Symbol.for("HORIZONTAL_TILING"),
FIT_TO_HEIGHT: Symbol.for("FIT_TO_HEIGHT"),
VARIABLE: Symbol.for("VARIABLE"),
};

View File

@ -0,0 +1,331 @@
/**
* Particle Emitter.
*
* @author Nikita Agafonov
* @version 2023.2.0
* @copyright (c) 2020-2023 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
*/
import * as PIXI from "pixi.js-legacy";
const DEFAULT_POOL_SIZE = 1024;
const DEFAULT_PARTICLE_WIDTH = 10;
const DEFAULT_PARTICLE_HEIGHT = 10;
const DEFAULT_PARTICLE_LIFETIME = 3; // Seconds.
const DEFAULT_PARTICLE_COLOR = 0xffffff;
const DEFAULT_PARTICLES_PER_SEC = 60;
const DEFAULT_PARTICLE_V = 100;
class Particle
{
constructor (cfg)
{
this.x = 0;
this.y = 0;
this.ax = 0;
this.ay = 0;
this.vx = 0;
this.vy = 0;
this.lifeTime = 0;
this.widthChange = 0;
this.heightChange = 0;
this.sprite = undefined;
this.inUse = false;
if (cfg.particleImage !== undefined)
{
this.sprite = PIXI.Sprite.from(PIXI.Texture.from(cfg.particleImage));
}
else
{
this.sprite = new PIXI.Sprite(PIXI.Texture.WHITE);
this.sprite.tint = cfg.particleColor || DEFAULT_PARTICLE_COLOR;
}
// TODO: Should we instead incorporate that in position calculation?
// Consider: accurate spawn position of the particle confined by spawnArea.
this.sprite.anchor.set(0.5);
this.width = cfg.particleWidth || DEFAULT_PARTICLE_WIDTH;
this.height = cfg.particleHeight || DEFAULT_PARTICLE_HEIGHT;
}
set width (w)
{
this._width = w;
this.sprite.width = w;
}
get width ()
{
return this._width;
}
set height (h)
{
this._height = h;
this.sprite.height = h;
}
get height ()
{
return this._height;
}
update (dt)
{
const dt2 = dt * dt;
// Update velocity with current acceleration.
this.vx += this.ax * dt;
this.vy += this.ay * dt;
// Update position with current velocity and acceleration.
this.x = this.x + this.vx * dt + this.ax * dt2 * .5;
this.y = this.y + this.vy * dt + this.ay * dt2 * .5;
this.sprite.rotation = Math.atan2(this.vy, this.vx);
this.sprite.x = this.x;
this.sprite.y = this.y;
if (this.width > 0)
{
this.width = Math.max(0, this.width + this.widthChange);
}
if (this.height > 0)
{
this.height = Math.max(0, this.height + this.heightChange);
}
this.lifeTime -= dt;
if (this.width <= 0 && this.height <= 0)
{
this.lifeTime = 0;
}
if (this.lifeTime <= 0)
{
this.inUse = false;
}
}
}
export class ParticleEmitter
{
constructor (cfg = {})
{
this.x = 0;
this.y = 0;
this._cfg = cfg;
this._particlesPerSec = cfg.particlesPerSec || DEFAULT_PARTICLES_PER_SEC;
this._spawnCoolDown = 0;
this._parentObj = undefined;
this._particlePool = new Array(DEFAULT_POOL_SIZE);
this.setParentObject(cfg.parentObject);
this._fillParticlePool(cfg);
}
_fillParticlePool (cfg)
{
let i;
for (i = 0; i < this._particlePool.length; i++)
{
this._particlePool[i] = new Particle(cfg);
}
}
_setupParticle (p)
{
let spawnAreaWidth = this._cfg.spawnAreaWidth || 0;
let spawnAreaHeight = this._cfg.spawnAreaHeight || 0;
if (this._parentObj !== undefined && this._cfg.useParentSizeAsSpawnArea)
{
spawnAreaWidth = this._parentObj.width;
spawnAreaHeight = this._parentObj.height;
}
const spawnOffsetX = Math.random() * spawnAreaWidth - spawnAreaWidth * .5;
const spawnOffsetY = Math.random() * spawnAreaHeight - spawnAreaHeight * .5;
const x = this.x + spawnOffsetX;
const y = this.y + spawnOffsetY;
p.x = x;
p.y = y;
p.ax = 0;
p.ay = 0;
if (Number.isFinite(this._cfg.initialVx))
{
p.vx = this._cfg.initialVx;
}
else if (this._cfg.initialVx instanceof Array && this._cfg.initialVx.length >= 2)
{
p.vx = Math.random() * (this._cfg.initialVx[1] - this._cfg.initialVx[0]) + this._cfg.initialVx[0];
}
else
{
p.vx = Math.random() * DEFAULT_PARTICLE_V - DEFAULT_PARTICLE_V * .5;
}
if (Number.isFinite(this._cfg.initialVy))
{
p.vy = this._cfg.initialVy;
}
else if (this._cfg.initialVy instanceof Array && this._cfg.initialVy.length >= 2)
{
p.vy = Math.random() * (this._cfg.initialVy[1] - this._cfg.initialVy[0]) + this._cfg.initialVy[0];
}
else
{
p.vy = Math.random() * DEFAULT_PARTICLE_V - DEFAULT_PARTICLE_V * .5;
}
p.lifeTime = this._cfg.lifeTime || DEFAULT_PARTICLE_LIFETIME;
p.width = this._cfg.particleWidth || DEFAULT_PARTICLE_WIDTH;
p.height = this._cfg.particleHeight || DEFAULT_PARTICLE_HEIGHT;
p.widthChange = this._cfg.particleWidthChange || 0;
p.heightChange = this._cfg.particleHeightChange || 0;
// TODO: run proper checks here.
if (this._cfg.particleImage)
{
p.sprite.texture = PIXI.Texture.from(this._cfg.particleImage);
}
else
{
p.sprite.texture = PIXI.Texture.WHITE;
}
if (this._cfg.particleColor !== undefined)
{
p.sprite.tint = this._cfg.particleColor;
}
else
{
p.sprite.tint = 0xffffff;
}
}
_spawnParticles (n = 0)
{
let i;
for (i = 0; i < this._particlePool.length && n > 0; i++)
{
if (this._particlePool[i].inUse === false)
{
this._particlePool[i].inUse = true;
n--;
this._setupParticle(this._particlePool[i]);
this._cfg.container.addChild(this._particlePool[i].sprite);
}
}
}
_getResultingExternalForce ()
{
let externalForce = [0, 0];
if (this._cfg.externalForces instanceof Array)
{
let i;
for (i = 0; i < this._cfg.externalForces.length; i++)
{
externalForce[0] += this._cfg.externalForces[i][0];
externalForce[1] += this._cfg.externalForces[i][1];
}
}
return externalForce;
}
setParentObject (po)
{
this._parentObj = po;
}
/**
* @desc: Adds external force which acts on a particle
* @param: f - Array with two elements, first is x component, second is y component.
* It's a vector of length L which sets the direction and the margnitude of the force.
* */
addExternalForce (f)
{
this._cfg.externalForces.push(f);
}
removeExternalForce (f)
{
const i = this._cfg.externalForces.indexOf(f);
if (i !== -1)
{
this._cfg.externalForces.splice(i, 1);
}
}
removeExternalForceByIdx (idx)
{
if (this._cfg.externalForces[idx] !== undefined)
{
this._cfg.externalForces.splice(idx, 1);
}
}
update (dt)
{
let externalForce;
// Sync with parent object if it exists.
if (this._parentObj !== undefined)
{
this.x = this._parentObj.x;
this.y = this._parentObj.y;
}
if (Number.isFinite(this._cfg.positionOffsetX))
{
this.x += this._cfg.positionOffsetX;
}
if (Number.isFinite(this._cfg.positionOffsetY))
{
this.y += this._cfg.positionOffsetY;
}
if (this._spawnCoolDown <= 0)
{
this._spawnCoolDown = 1 / this._particlesPerSec;
// Assuming that we have at least 60FPS.
const frameTime = Math.min(dt, 1 / 60);
const particlesPerFrame = Math.ceil(frameTime / this._spawnCoolDown);
this._spawnParticles(particlesPerFrame);
}
else
{
this._spawnCoolDown -= dt;
}
let i;
for (i = 0; i < this._particlePool.length; i++)
{
if (this._particlePool[i].inUse)
{
externalForce = this._getResultingExternalForce();
this._particlePool[i].ax = externalForce[0];
this._particlePool[i].ay = externalForce[1];
this._particlePool[i].update(dt);
}
// Check if particle should be removed.
if (this._particlePool[i].lifeTime <= 0 && this._particlePool[i].sprite.parent)
{
this._cfg.container.removeChild(this._particlePool[i].sprite);
}
}
}
}

162
src/visual/Progress.js Normal file
View File

@ -0,0 +1,162 @@
import * as PIXI from "pixi.js-legacy";
import * as util from "../util/Util.js";
import { Color } from "../util/Color.js";
import { to_pixiPoint } from "../util/Pixi.js";
import { VisualStim } from "./VisualStim.js";
export class Progress extends VisualStim
{
constructor (
{
name,
win,
units = "pix",
ori,
opacity,
depth,
pos,
anchor = "left",
size = [300, 30],
clipMask,
autoDraw,
autoLog,
progress = 1,
type,
fillColor,
fillTexture
})
{
super({
name,
win,
units,
ori,
opacity,
depth,
pos,
anchor,
size,
clipMask,
autoDraw,
autoLog
});
this._addAttribute("progress", progress, 0);
this._addAttribute("type", type, PROGRESS_TYPES.BAR);
this._addAttribute("fillColor", fillColor, "lightgreen");
this._addAttribute("fillTexture", fillTexture, PIXI.Texture.WHITE);
if (this._autoLog)
{
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
}
}
/**
* Setter for the progress attribute.
*/
setProgress (progress = 0, log = false)
{
this._setAttribute("progress", Math.min(1.0, Math.max(0.0, progress)), log);
if (this._pixi !== undefined)
{
this._pixi.clear();
const size_px = util.to_px(this._size, this._units, this._win);
const progressWidth = size_px[0] * this._progress;
if (this._fillTexture)
{
let t = PIXI.Texture.WHITE;
if (typeof this._fillTexture === "string")
{
t = PIXI.Texture.from(this._fillTexture);
t.baseTexture.scaleMode = PIXI.SCALE_MODES.NEAREST;
}
this._pixi.beginTextureFill({
texture: t
});
}
else
{
this._pixi.beginFill(new Color(this._fillColor).int, this._opacity);
}
if (this._type === PROGRESS_TYPES.BAR)
{
this._pixi.drawRect(0, 0, progressWidth, size_px[1]);
}
this._pixi.endFill();
// TODO: is there a better way to ensure anchor works?
this.anchor = this._anchor;
}
}
/**
* Estimate the bounding box.
*
* @override
* @protected
*/
_estimateBoundingBox()
{
let boundingBox = new PIXI.Rectangle(0, 0, 0, 0);
const anchorNum = this._anchorTextToNum(this._anchor);
const pos_px = util.to_px(this._pos, this._units, this._win);
const size_px = util.to_px(this._size, this._units, this._win);
boundingBox.x = pos_px[ 0 ] - anchorNum[ 0 ] * size_px[ 0 ];
boundingBox.y = pos_px[ 1 ] - anchorNum[ 1 ] * size_px[ 1 ];
boundingBox.width = size_px[ 0 ];
boundingBox.height = size_px[ 1 ];
this._boundingBox = boundingBox;
}
/**
* Update the stimulus, if necessary.
*
* @protected
*/
_updateIfNeeded()
{
// TODO: figure out what is the error with estimateBoundBox on resize?
if (!this._needUpdate)
{
return;
}
this._needUpdate = false;
// update the PIXI representation, if need be:
if (this._needPixiUpdate)
{
this._needPixiUpdate = false;
if (typeof this._pixi !== "undefined")
{
this._pixi.destroy(true);
}
this._pixi = new PIXI.Graphics();
// TODO: Should we do this?
// this._pixi.lineStyle(this._lineWidth, this._lineColor.int, this._opacity, 0.5);
// TODO: Should just .setProgress() be called?
this.setProgress(this._progress);
this._pixi.scale.y = -1;
this._pixi.zIndex = -this._depth;
this.anchor = this._anchor;
}
// set polygon position and rotation:
this._pixi.position = to_pixiPoint(this._pos, this._units, this._win);
this._pixi.rotation = -this.ori * Math.PI / 180.0;
this._estimateBoundingBox();
}
}
export const PROGRESS_TYPES =
{
BAR: 0,
CIRCLE: 1
}

View File

@ -402,4 +402,29 @@ ShapeStim.KnownShapes = {
[-0.39, 0.31],
[-0.09, 0.18],
],
triangle: [
[+0.0, 0.5], // Point
[-0.5, -0.5], // Bottom left
[+0.5, -0.5], // Bottom right
],
rectangle: [
[-.5, .5], // Top left
[ .5, .5], // Top right
[ .5, -.5], // Bottom left
[-.5, -.5], // Bottom right
],
arrow: [
[0.0, 0.5],
[-0.5, 0.0],
[-1/6, 0.0],
[-1/6, -0.5],
[1/6, -0.5],
[1/6, 0.0],
[0.5, 0.0],
],
};
// Alias some names for convenience
ShapeStim.KnownShapes['star'] = ShapeStim.KnownShapes['star7']

View File

@ -82,6 +82,10 @@ export class Survey extends VisualStim
{
super({ name, win, units, ori, depth, pos, size, autoDraw, autoLog });
// Storing all existing signaturePad questions to properly handle their resize.
// Unfortunately signaturepad question type can't handle resizing properly by itself.
this._signaturePads = [];
// whether the user is done with the survey, independently of whether the survey is completed:
this.isFinished = false;
@ -968,8 +972,6 @@ export class Survey extends VisualStim
this.psychoJS.logger.warn(`Flag _isCompletedAll is false!`);
}
this._detachResizeObservers();
this._surveyRunningPromiseResolve(completionCode);
}
@ -1137,34 +1139,46 @@ export class Survey extends VisualStim
this._lastPageSwitchHandledIdx = -1;
}
_handleSignaturePadResize(entries)
_handleWindowResize(e)
{
for (let i = 0; i < entries.length; i++)
if (this._surveyModel)
{
// const signatureCanvas = entries[i].target.querySelector("canvas");
const question = this._surveyModel.getQuestionByName(entries[i].target.dataset.name);
question.signatureWidth = Math.min(question.maxSignatureWidth, entries[i].contentBoxSize[0].inlineSize);
for (let i = this._signaturePads.length - 1; i >= 0; i--)
{
// As of writing this (24.03.2023). SurveyJS doesn't have a proper event
// for question being removed from nested locations, such as dynamic panel.
// However, surveyJS will set .signaturePad property to null once the question is removed.
// Utilising this knowledge to sync our lists.
if (this._signaturePads[ i ].question.signaturePad)
{
this._signaturePads[ i ].question.signatureWidth = Math.min(
this._signaturePads[i].question.maxSignatureWidth,
this._signaturePads[ i ].htmlElement.getBoundingClientRect().width
);
}
else
{
// Signature pad was removed. Syncing list.
this._signaturePads.splice(i, 1);
}
}
}
}
_addEventListeners()
{
this._signaturePadRO = new ResizeObserver(this._handleSignaturePadResize.bind(this));
window.addEventListener("resize", (e) => this._handleWindowResize(e));
}
_handleAfterQuestionRender (sender, options)
{
if (options.question.getType() === "signaturepad")
{
this._signaturePadRO.observe(options.htmlElement);
this._signaturePads.push(options);
options.question.signatureWidth = Math.min(options.question.maxSignatureWidth, options.htmlElement.getBoundingClientRect().width);
}
}
_detachResizeObservers()
{
this._signaturePadRO.disconnect();
}
/**
* Init the SurveyJS.io library and various extensions, setup the theme.
*

View File

@ -88,7 +88,8 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
autoDraw,
autoLog,
fitToContent,
draggable
draggable,
boxFn
} = {},
)
{
@ -204,12 +205,14 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
// and setSize called from super class would not have a proper effect
this.setSize(size);
this._addAttribute("boxFn", boxFn, null);
// estimate the bounding box:
this._estimateBoundingBox();
if (this._autoLog)
{
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${util.toString(this)}`);
}
}
@ -483,6 +486,26 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
alignmentStyles = ["center", "center"];
}
let box;
if (this._boxFn !== null)
{
box = this._boxFn;
}
else
{
// note: box style properties eventually become PIXI.Graphics settings, so same syntax applies
box = {
fill: new Color(this._fillColor).int,
alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px,
alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1
}
};
}
return {
// input style properties eventually become CSS, so same syntax applies
input: {
@ -506,41 +529,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin)
overflow: "hidden",
pointerEvents: "none"
},
// box style properties eventually become PIXI.Graphics settings, so same syntax applies
box: {
fill: new Color(this._fillColor).int,
alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px,
alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1
},
/*default: {
fill: new Color(this._fillColor).int,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px
}
},
focused: {
fill: new Color(this._fillColor).int,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px
}
},
disabled: {
fill: new Color(this._fillColor).int,
rounded: 5,
stroke: {
color: new Color(this._borderColor).int,
width: borderWidth_px
}
}*/
},
box
};
}

View File

@ -2,6 +2,7 @@ export * from "./ButtonStim.js";
export * from "./Form.js";
export * from "./ImageStim.js";
export * from "./GratingStim.js";
export * from "./GifStim.js";
export * from "./MovieStim.js";
export * from "./Polygon.js";
export * from "./Rect.js";
@ -13,3 +14,5 @@ export * from "./TextStim.js";
export * from "./VisualStim.js";
export * from "./FaceDetector.js";
export * from "./Survey.js";
export * from "./ParticleEmitter.js";
export * from "./Progress.js";

View File

@ -95,18 +95,11 @@ class MaxDiffMatrix
question.setCssRoot(rootClass);
question.cssClasses.mainRoot = rootClass;
}
let html;
let headerCells = "";
let subHeaderCells = "";
let bodyCells = "";
let bodyHTML = "";
let cellGenerator;
let i, j;
// Relying on a fact that there's always 2 columns.
// This is correct according current Qualtrics design for MaxDiff matrices.
// Header generation
headerCells =
let headerCells =
`<th class="${CSS_CLASSES.TABLE_HEADER_CELL}">${question.columns[0].text}</th>
<td></td>
<td></td>
@ -114,9 +107,10 @@ class MaxDiffMatrix
<th class="${CSS_CLASSES.TABLE_HEADER_CELL}">${question.columns[1].text}</th>`;
// Body generation
for (i = 0; i < question.rows.length; i++)
let bodyHTML = "";
for (let i = 0; i < question.rows.length; i++)
{
bodyCells =
const bodyCells =
`<td class="${CSS_CLASSES.TABLE_CELL}">
<label class="${CSS_CLASSES.LABEL}">
<input type="radio" class="${CSS_CLASSES.ITEM_VALUE}" name="${question.rows[i].value}" data-column=${question.columns[0].value}>
@ -135,7 +129,7 @@ class MaxDiffMatrix
bodyHTML += `<tr class="${CSS_CLASSES.TABLE_ROW}">${bodyCells}</tr>`;
}
html = `<table class="${CSS_CLASSES.TABLE}">
let html = `<table class="${CSS_CLASSES.TABLE}">
<thead>
<tr>${headerCells}</tr>
</thead>
@ -147,14 +141,15 @@ class MaxDiffMatrix
let inputDOMS = el.querySelectorAll("input");
for (i = 0; i < inputDOMS.length; i++)
for (let i = 0; i < inputDOMS.length; i++)
{
inputDOMS[i].addEventListener("input", this._bindedHandlers._handleInput);
}
}
}
export default function init (Survey) {
export default function init (Survey)
{
var widget = {
//the widget name. It should be unique and written in lowcase.
name: "maxdiffmatrix",

40
vite.config.js Normal file
View File

@ -0,0 +1,40 @@
import glsl from "vite-plugin-glsl"
const fileName = `psychojs-${process.env.npm_package_version}`;
export default {
root: "./src/",
base: "./",
build:
{
outDir: "../out",
emptyOutDir: true,
sourcemap: true,
minify: false,
cssCodeSplit: true,
lib:
{
name: "psychojs",
fileName,
entry: ["index.js", "index.css"]
},
// rollupOptions:
// {
// // make sure to externalize deps that shouldn't be bundled
// // into your library
// external: ['vue'],
// output:
// {
// // Provide global variables to use in the UMD build
// // for externalized deps
// globals: {
// vue: 'Vue',
// },
// },
// }
},
plugins:
[
glsl()
]
}