mirror of
https://github.com/psychopy/psychojs.git
synced 2025-05-10 10:40:54 +00:00
1252 lines
34 KiB
HTML
1252 lines
34 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>JSDoc: Source: visual/Form.js</title>
|
|
|
|
<script src="scripts/prettify/prettify.js"> </script>
|
|
<script src="scripts/prettify/lang-css.js"> </script>
|
|
<!--[if lt IE 9]>
|
|
<script src="//html5shiv.googlecode.com/svn/trunk/html5.js"></script>
|
|
<![endif]-->
|
|
<link type="text/css" rel="stylesheet" href="styles/prettify-tomorrow.css">
|
|
<link type="text/css" rel="stylesheet" href="styles/jsdoc-default.css">
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<div id="main">
|
|
|
|
<h1 class="page-title">Source: visual/Form.js</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<section>
|
|
<article>
|
|
<pre class="prettyprint source linenums"><code>/**
|
|
* Form Stimulus.
|
|
*
|
|
* @author Alain Pitiot
|
|
* @version 2021.2.0
|
|
* @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
|
|
* @license Distributed under the terms of the MIT License
|
|
*/
|
|
|
|
import * as PIXI from "pixi.js-legacy";
|
|
import { TrialHandler } from "../data/TrialHandler.js";
|
|
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 { Slider } from "./Slider.js";
|
|
import { TextBox } from "./TextBox.js";
|
|
import { TextStim } from "./TextStim.js";
|
|
import { VisualStim } from "./VisualStim.js";
|
|
|
|
/**
|
|
* Form stimulus.
|
|
*
|
|
* @name module:visual.Form
|
|
* @class
|
|
* @extends module:visual.VisualStim
|
|
* @mixes module:util.ColorMixin
|
|
*
|
|
* @param {Object} options
|
|
* @param {String} options.name - the name used when logging messages from this stimulus
|
|
* @param {module:core.Window} options.win - the associated Window
|
|
* @param {number[]} [options.pos= [0, 0]] - the position of the center of the slider
|
|
* @param {number[]} options.size - the size of the slider, e.g. [1, 0.1] for an horizontal slider
|
|
* @param {string} [options.units= 'height'] - the units of the Slider position, and font size
|
|
*
|
|
* @param {Color} [options.color= Color('LightGray')] the color of the slider
|
|
* @param {number} [options.contrast= 1.0] - the contrast of the slider
|
|
* @param {number} [options.opacity= 1.0] - the opacity of the slider
|
|
* @param {number} [options.depth= 0] - the depth (i.e. the z order), note that the text, radio buttons and slider elements are at depth + 1
|
|
*
|
|
* @param {number[]} [options.items= []] - the array of labels
|
|
* @param {number} [options.itemPadding= 0.05] - the granularity
|
|
*
|
|
* @param {string} [options.font= 'Arial'] - the text font
|
|
* @param {string} [options.fontFamily= 'Helvetica'] - the text font
|
|
* @param {boolean} [options.bold= true] - whether or not the font of the labels is bold
|
|
* @param {boolean} [options.italic= false] - whether or not the font of the labels is italic
|
|
* @param {number} [options.fontSize] - the font size of the labels (in form units), the default fontSize
|
|
* depends on the Form units: 14 for 'pix', 0.03 otherwise
|
|
*
|
|
* @param {PIXI.Graphics} [options.clipMask= null] - the clip mask
|
|
* @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 Form extends util.mix(VisualStim).with(ColorMixin)
|
|
{
|
|
constructor(
|
|
{
|
|
name,
|
|
win,
|
|
pos,
|
|
size,
|
|
units,
|
|
borderColor,
|
|
fillColor,
|
|
itemColor,
|
|
markerColor,
|
|
responseColor,
|
|
color,
|
|
contrast,
|
|
opacity,
|
|
depth,
|
|
items,
|
|
randomize,
|
|
itemPadding,
|
|
font,
|
|
fontFamily,
|
|
bold,
|
|
italic,
|
|
fontSize,
|
|
clipMask,
|
|
autoDraw,
|
|
autoLog,
|
|
} = {},
|
|
)
|
|
{
|
|
super({ name, win, units, opacity, depth, pos, size, clipMask, autoDraw, autoLog });
|
|
|
|
this._addAttribute(
|
|
"itemPadding",
|
|
itemPadding,
|
|
util.to_unit([20, 0], "pix", win, this._units)[0],
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
// colors:
|
|
this._addAttribute(
|
|
"color",
|
|
// Same as itemColor
|
|
color,
|
|
undefined,
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
this._addAttribute(
|
|
"borderColor",
|
|
borderColor,
|
|
fillColor,
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
this._addAttribute(
|
|
"fillColor",
|
|
fillColor,
|
|
undefined,
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
this._addAttribute(
|
|
"itemColor",
|
|
itemColor,
|
|
undefined,
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
this._addAttribute(
|
|
"markerColor",
|
|
markerColor,
|
|
undefined,
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
this._addAttribute(
|
|
"responseColor",
|
|
responseColor,
|
|
undefined,
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
this._addAttribute(
|
|
"contrast",
|
|
contrast,
|
|
1.0,
|
|
this._onChange(true, false),
|
|
);
|
|
|
|
// fonts:
|
|
this._addAttribute(
|
|
"font",
|
|
font,
|
|
"Arial",
|
|
this._onChange(true, true),
|
|
);
|
|
// Not in use at present
|
|
this._addAttribute(
|
|
"fontFamily",
|
|
fontFamily,
|
|
"Helvetica",
|
|
this._onChange(true, true),
|
|
);
|
|
this._addAttribute(
|
|
"fontSize",
|
|
fontSize,
|
|
(this._units === "pix") ? 14 : 0.03,
|
|
this._onChange(true, true),
|
|
);
|
|
this._addAttribute(
|
|
"bold",
|
|
bold,
|
|
false,
|
|
this._onChange(true, true),
|
|
);
|
|
this._addAttribute(
|
|
"italic",
|
|
italic,
|
|
false,
|
|
this._onChange(true, true),
|
|
);
|
|
|
|
// callback to deal with changes to items:
|
|
const onItemChange = () =>
|
|
{
|
|
// reprocess the items:
|
|
this._processItems();
|
|
|
|
// setup the stimuli:
|
|
this._setupStimuli();
|
|
|
|
this._onChange(true, true)();
|
|
};
|
|
|
|
this._addAttribute(
|
|
"items",
|
|
items,
|
|
[],
|
|
onItemChange,
|
|
);
|
|
this._addAttribute(
|
|
"randomize",
|
|
randomize,
|
|
false,
|
|
onItemChange,
|
|
);
|
|
|
|
this._scrollbarWidth = 0.02;
|
|
this._responseTextHeightRatio = 0.8;
|
|
|
|
// process the items:
|
|
this._processItems();
|
|
|
|
// setup the stimuli:
|
|
this._setupStimuli();
|
|
|
|
if (this._autoLog)
|
|
{
|
|
this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Force a refresh of the stimulus.
|
|
*
|
|
* @name module:visual.Form#refresh
|
|
* @function
|
|
* @public
|
|
*/
|
|
refresh()
|
|
{
|
|
super.refresh();
|
|
|
|
for (let i = 0; i < this._items.length; ++i)
|
|
{
|
|
const textStim = this._visual.textStims[i];
|
|
textStim.refresh();
|
|
|
|
const responseStim = this._visual.responseStims[i];
|
|
if (responseStim)
|
|
{
|
|
responseStim.refresh();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Overridden draw that also calls the draw method of all form elements.
|
|
*
|
|
* @name module:visual.Form#draw
|
|
* @function
|
|
* @public
|
|
* @override
|
|
*/
|
|
draw()
|
|
{
|
|
// if the scrollbar's marker position has changed then the layout must be updated:
|
|
if (this._scrollbar.markerPos !== this._prevScrollbarMarkerPos)
|
|
{
|
|
this._prevScrollbarMarkerPos = this._scrollbar.markerPos;
|
|
this._needUpdate = true;
|
|
}
|
|
|
|
// draw the decorations:
|
|
super.draw();
|
|
|
|
// draw the stimuli:
|
|
for (let i = 0; i < this._items.length; ++i)
|
|
{
|
|
if (this._visual.visibles[i])
|
|
{
|
|
const textStim = this._visual.textStims[i];
|
|
textStim.draw();
|
|
|
|
const responseStim = this._visual.responseStims[i];
|
|
if (responseStim)
|
|
{
|
|
responseStim.draw();
|
|
}
|
|
}
|
|
}
|
|
|
|
// draw the scrollbar:
|
|
this._scrollbar.draw();
|
|
}
|
|
|
|
/**
|
|
* Overridden hide that also calls the hide method of all form elements.
|
|
*
|
|
* @name module:visual.Form#hide
|
|
* @function
|
|
* @public
|
|
* @override
|
|
*/
|
|
hide()
|
|
{
|
|
// hide the decorations:
|
|
super.hide();
|
|
|
|
// hide the stimuli:
|
|
if (typeof this._items !== "undefined")
|
|
{
|
|
for (let i = 0; i < this._items.length; ++i)
|
|
{
|
|
if (this._visual.visibles[i])
|
|
{
|
|
const textStim = this._visual.textStims[i];
|
|
textStim.hide();
|
|
|
|
const responseStim = this._visual.responseStims[i];
|
|
if (responseStim)
|
|
{
|
|
responseStim.hide();
|
|
}
|
|
}
|
|
}
|
|
|
|
// hide the scrollbar:
|
|
this._scrollbar.hide();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset the form.
|
|
*
|
|
* @name module:visual.Form#reset
|
|
* @function
|
|
* @public
|
|
*/
|
|
reset()
|
|
{
|
|
this.psychoJS.logger.debug("reset Form: ", this._name);
|
|
|
|
// reset the stimuli:
|
|
for (let i = 0; i < this._items.length; ++i)
|
|
{
|
|
const textStim = this._visual.textStims[i];
|
|
textStim.reset();
|
|
|
|
const responseStim = this._visual.responseStims[i];
|
|
if (responseStim)
|
|
{
|
|
responseStim.reset();
|
|
}
|
|
}
|
|
|
|
this._needUpdate = true;
|
|
}
|
|
|
|
/**
|
|
* Collate the questions and responses into a single dataset.
|
|
*
|
|
* @name module:visual.Form#getData
|
|
* @function
|
|
* @public
|
|
* @return {object} - the dataset with all questions and responses.
|
|
*/
|
|
getData()
|
|
{
|
|
let nbIncompleteResponse = 0;
|
|
|
|
for (let i = 0; i < this._items.length; ++i)
|
|
{
|
|
const item = this._items[i];
|
|
const responseStim = this._visual.responseStims[i];
|
|
if (responseStim)
|
|
{
|
|
if (item.type === Form.Types.CHOICE || item.type === Form.Types.RATING || item.type === Form.Types.SLIDER)
|
|
{
|
|
item.response = responseStim.getRating();
|
|
item.rt = responseStim.getRT();
|
|
|
|
if (typeof item.response === "undefined")
|
|
{
|
|
++nbIncompleteResponse;
|
|
}
|
|
}
|
|
else if (item.type === Form.Types.FREE_TEXT)
|
|
{
|
|
item.response = responseStim.text;
|
|
item.rt = undefined;
|
|
|
|
if (item.response.length === 0)
|
|
{
|
|
++nbIncompleteResponse;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this._items._complete = (nbIncompleteResponse === 0);
|
|
|
|
// return a copy of this._items:
|
|
return this._items.map((item) => Object.assign({}, item));
|
|
}
|
|
/**
|
|
* Check if the form is complete.
|
|
*
|
|
* @name module:visual.Form#formComplete
|
|
* @function
|
|
* @public
|
|
* @return {boolean} - whether there are any remaining incomplete responses.
|
|
*/
|
|
formComplete()
|
|
{
|
|
// same as complete but might be used by some experiments before 2020.2
|
|
this.getData();
|
|
return this._items._complete;
|
|
}
|
|
/**
|
|
* Add the form data to the given experiment.
|
|
*
|
|
* @name module:visual.Form#addDataToExp
|
|
* @function
|
|
* @public
|
|
* @param {module:data.ExperimentHandler} experiment - the experiment into which to insert the form data
|
|
* @param {string} [format= 'rows'] - whether to insert the data as rows or as columns
|
|
*/
|
|
addDataToExp(experiment, format = "rows")
|
|
{
|
|
const addAsColumns = ["cols", "columns"].includes(format.toLowerCase());
|
|
const data = this.getData();
|
|
|
|
const _doNotSave = [
|
|
"itemCtrl",
|
|
"responseCtrl",
|
|
"itemColor",
|
|
"options",
|
|
"ticks",
|
|
"tickLabels",
|
|
"responseWidth",
|
|
"responseColor",
|
|
"layout",
|
|
];
|
|
|
|
for (const item of this.getData())
|
|
{
|
|
let index = 0;
|
|
for (const field in item)
|
|
{
|
|
if (!_doNotSave.includes(field))
|
|
{
|
|
const columnName = (addAsColumns) ? `${this._name}[${index}]${field}` : `${this._name}${field}`;
|
|
experiment.addData(columnName, item[field]);
|
|
}
|
|
++index;
|
|
}
|
|
|
|
if (!addAsColumns)
|
|
{
|
|
experiment.nextEntry();
|
|
}
|
|
}
|
|
|
|
if (addAsColumns)
|
|
{
|
|
experiment.nextEntry();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import and process the form items from either a spreadsheet resource files (.csv, .xlsx, etc.) or from an array.
|
|
*
|
|
* @name module:visual.Form#_processItems
|
|
* @function
|
|
* @private
|
|
*/
|
|
_processItems()
|
|
{
|
|
const response = {
|
|
origin: "Form._processItems",
|
|
context: "when processing the form items",
|
|
};
|
|
|
|
try
|
|
{
|
|
if (this._autoLog)
|
|
{
|
|
// note: we use the same log message as PsychoPy even though we called this method differently
|
|
this._psychoJS.experimentLogger.exp("Importing items...");
|
|
}
|
|
|
|
// import the items:
|
|
this._importItems();
|
|
|
|
// sanitize the items (check that keys are valid, fill in default values):
|
|
this._sanitizeItems();
|
|
|
|
// randomise the items if need be:
|
|
if (this._randomize)
|
|
{
|
|
util.shuffle(this._items);
|
|
}
|
|
}
|
|
catch (error)
|
|
{
|
|
// throw { ...response, error };
|
|
throw Object.assign(response, { error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Import the form items from either a spreadsheet resource files (.csv, .xlsx, etc.) or from an array.
|
|
*
|
|
* @name module:visual.Form#_importItems
|
|
* @function
|
|
* @private
|
|
*/
|
|
_importItems()
|
|
{
|
|
const response = {
|
|
origin: "Form._importItems",
|
|
context: "when importing the form items",
|
|
};
|
|
|
|
try
|
|
{
|
|
const itemsType = typeof this._items;
|
|
|
|
// we treat undefined items as a list with a single default entry:
|
|
if (itemsType === "undefined")
|
|
{
|
|
this._items = [Form._defaultItems];
|
|
}
|
|
// if items is a string, we treat it as the name of a resource file and import it:
|
|
else if (itemsType === "string")
|
|
{
|
|
this._items = TrialHandler.importConditions(this._psychoJS.serverManager, this._items);
|
|
}
|
|
// unknown items type:
|
|
else
|
|
{
|
|
throw `unable to import items of unknown type: ${itemsType}`;
|
|
}
|
|
|
|
// if items is an empty array, we replace with a single default entry:
|
|
if (Array.isArray(this._items) && this._items.length === 0)
|
|
{
|
|
this._items = [Form._defaultItems];
|
|
}
|
|
}
|
|
catch (error)
|
|
{
|
|
// throw { ...response, error };
|
|
throw Object.assign(response, { error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize the form items: check that the keys are valid, and fill in default values.
|
|
*
|
|
* @name module:visual.Form#_sanitizeItems
|
|
* @function
|
|
* @private
|
|
*/
|
|
_sanitizeItems()
|
|
{
|
|
const response = {
|
|
origin: "Form._sanitizeItems",
|
|
context: "when sanitizing the form items",
|
|
};
|
|
|
|
try
|
|
{
|
|
// convert old style questionnaire to new style:
|
|
for (const item of this._items)
|
|
{
|
|
// old style forms have questionText instead of itemText:
|
|
if (typeof item.questionText !== "undefined")
|
|
{
|
|
item.itemText = item.questionText;
|
|
delete item.questionText;
|
|
|
|
item.itemWidth = item.questionWidth;
|
|
delete item.questionWidth;
|
|
|
|
// for items of type 'rating, the ticks are in 'options' instead of in 'ticks':
|
|
if (item.type === "rating" || item.type === "slider")
|
|
{
|
|
item.ticks = item.options;
|
|
item.options = undefined;
|
|
}
|
|
}
|
|
}
|
|
|
|
// fill in missing keys and undefined values:
|
|
const defaultKeys = Object.keys(Form._defaultItems);
|
|
const missingKeys = new Set();
|
|
for (const item of this._items)
|
|
{
|
|
const itemKeys = Object.keys(item);
|
|
for (const key of defaultKeys)
|
|
{
|
|
// missing key:
|
|
if (!itemKeys.includes(key))
|
|
{
|
|
missingKeys.add(key);
|
|
item[key] = Form._defaultItems[key];
|
|
}
|
|
// undefined value:
|
|
else if (typeof item[key] === "undefined")
|
|
{
|
|
// TODO: options = '' for FREE_TEXT
|
|
item[key] = Form._defaultItems[key];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (missingKeys.size > 0)
|
|
{
|
|
this._psychoJS.logger.warn(
|
|
`Missing headers: ${Array.from(missingKeys).join(", ")}\nNote, headers are case sensitive and must match: ${Array.from(defaultKeys).join(", ")}`,
|
|
);
|
|
}
|
|
|
|
// check the types and options:
|
|
const formTypes = Object.getOwnPropertyNames(Form.Types);
|
|
for (const item of this._items)
|
|
{
|
|
// convert type to upper case, replace spaces by underscores
|
|
item.type = item.type.toUpperCase().replace(" ", "_");
|
|
|
|
// check that the type is valid:
|
|
if (!formTypes.includes(item.type))
|
|
{
|
|
throw `${item.type} is not a valid type for item: ${item.itemText}`;
|
|
}
|
|
|
|
// Support the 'radio' type found on older versions of PsychoPy
|
|
if (item.type === "RADIO")
|
|
{
|
|
item.type = "CHOICE";
|
|
}
|
|
|
|
// convert item type to symbol:
|
|
item.type = Symbol.for(item.type);
|
|
|
|
// turn the option into an array and check length, where applicable:
|
|
if (item.type === Form.Types.CHOICE)
|
|
{
|
|
item.options = item.options.split(",");
|
|
if (item.options.length < 2)
|
|
{
|
|
throw `at least two choices should be provided for choice item: ${item.itemText}`;
|
|
}
|
|
}
|
|
// turn the ticks and tickLabels into arrays, where applicable:
|
|
else if (item.type === Form.Types.RATING || item.type === Form.Types.SLIDER)
|
|
{
|
|
item.ticks = item.ticks.split(",").map((_, t) => parseInt(t));
|
|
item.tickLabels = (item.tickLabels.length > 0) ? item.tickLabels.split(",") : [];
|
|
}
|
|
|
|
// TODO
|
|
// estimate potentially missing itemWidth or responseWidth
|
|
// solve conflicts when itemWidth + responseWidth != 1
|
|
}
|
|
|
|
// check the layout:
|
|
const formLayouts = ["HORIZ", "VERT"];
|
|
for (const item of this._items)
|
|
{
|
|
// convert layout to upper case:
|
|
item.layout = item.layout.toUpperCase();
|
|
|
|
// check that the layout is valid:
|
|
if (!formLayouts.includes(item.layout))
|
|
{
|
|
throw `${item.layout} is not a valid layout for item: ${item.itemText}`;
|
|
}
|
|
|
|
// convert item layout to symbol:
|
|
item.layout = (item.layout === "HORIZ") ? Form.Layout.HORIZONTAL : Form.Layout.VERTICAL;
|
|
}
|
|
}
|
|
catch (error)
|
|
{
|
|
// throw { ...response, error };
|
|
throw Object.assign(response, { error });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Estimate the bounding box.
|
|
*
|
|
* @name module:visual.Form#_estimateBoundingBox
|
|
* @function
|
|
* @override
|
|
* @protected
|
|
*/
|
|
_estimateBoundingBox()
|
|
{
|
|
// take the alignment into account:
|
|
this._boundingBox = new PIXI.Rectangle(
|
|
this._pos[0] - this._size[0] / 2.0,
|
|
this._pos[1] - this._size[1] / 2.0,
|
|
this._size[0],
|
|
this._size[1],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Setup the stimuli, and the scrollbar.
|
|
*
|
|
* @name module:visual.Form#_setupStimuli
|
|
* @function
|
|
* @private
|
|
*/
|
|
_setupStimuli()
|
|
{
|
|
if (this._autoLog)
|
|
{
|
|
this._psychoJS.experimentLogger.exp(`Setting layout of Form: ${this.name}`);
|
|
}
|
|
|
|
// clean up the previously setup stimuli:
|
|
if (typeof this._visual !== "undefined")
|
|
{
|
|
for (const textStim of this._visual.textStims)
|
|
{
|
|
textStim.release();
|
|
}
|
|
for (const responseStim of this._visual.responseStims)
|
|
{
|
|
responseStim.release();
|
|
}
|
|
}
|
|
|
|
// visual representations of the items:
|
|
this._visual = {
|
|
rowHeights: [],
|
|
textStims: [],
|
|
responseStims: [],
|
|
visibles: [],
|
|
stimuliTotalHeight: 0,
|
|
};
|
|
|
|
// instantiate the clip mask that will be used by all stimuli:
|
|
this._stimuliClipMask = new PIXI.Graphics();
|
|
|
|
// default stimulus options:
|
|
const textStimOption = {
|
|
win: this._win,
|
|
name: "item text",
|
|
font: this.font,
|
|
units: this._units,
|
|
alignHoriz: "left",
|
|
alignVert: "top",
|
|
height: this._fontSize,
|
|
color: this.itemColor,
|
|
ori: 0,
|
|
opacity: 1,
|
|
depth: this._depth + 1,
|
|
clipMask: this._stimuliClipMask,
|
|
};
|
|
const sliderOption = {
|
|
win: this._win,
|
|
name: "choice response",
|
|
units: this._units,
|
|
flip: false,
|
|
// Not part of Slider options as things stand
|
|
fontFamily: this.fontFamily,
|
|
// As found in Slider options
|
|
font: this.font,
|
|
bold: false,
|
|
italic: false,
|
|
fontSize: this._fontSize * this._responseTextHeightRatio,
|
|
color: this.responseColor,
|
|
markerColor: this.markerColor,
|
|
opacity: 1,
|
|
depth: this._depth + 1,
|
|
clipMask: this._stimuliClipMask,
|
|
granularity: 1,
|
|
};
|
|
const textBoxOption = {
|
|
win: this._win,
|
|
name: "free text response",
|
|
units: this._units,
|
|
anchor: "left-top",
|
|
flip: false,
|
|
opacity: 1,
|
|
depth: this._depth + 1,
|
|
font: this.font,
|
|
letterHeight: this._fontSize * this._responseTextHeightRatio,
|
|
bold: false,
|
|
italic: false,
|
|
alignment: "left",
|
|
color: this.responseColor,
|
|
fillColor: this.fillColor,
|
|
contrast: 1.0,
|
|
borderColor: this.responseColor,
|
|
borderWidth: 0.002,
|
|
padding: 0.01,
|
|
editable: true,
|
|
clipMask: this._stimuliClipMask,
|
|
};
|
|
|
|
// we use for the slider's tick size the height of a word:
|
|
const textStim = new TextStim(Object.assign(textStimOption, { text: "Ag", pos: [0, 0] }));
|
|
const textMetrics_px = textStim.getTextMetrics();
|
|
const sliderTickSize = this._getLengthUnits(textMetrics_px.height) / 2;
|
|
textStim.release(false);
|
|
|
|
let stimulusOffset = -this._itemPadding;
|
|
for (const item of this._items)
|
|
{
|
|
// initially, all items are invisible:
|
|
this._visual.visibles.push(false);
|
|
|
|
// estimate row width:
|
|
// - heading: <padding> + <item> + <padding> + <scrollbar> = this._size[0]
|
|
// - description: <padding> + <item> + <padding> + <scrollbar> = this._size[0]
|
|
// - choice with vert layout: <padding> + <item> + <padding> + <scrollbar> = this._size[0]
|
|
let rowWidth;
|
|
if (
|
|
item.type === Form.Types.HEADING || item.type === Form.Types.DESCRIPTION
|
|
|| (item.type === Form.Types.CHOICE && item.layout === Form.Layout.VERTICAL)
|
|
)
|
|
{
|
|
rowWidth = (this._size[0] - this._itemPadding * 2 - this._scrollbarWidth);
|
|
}
|
|
// - anything else: <padding> + <item> + <padding> + <response> + <padding> + <scrollbar> = this._size[0]
|
|
else
|
|
{
|
|
rowWidth = (this._size[0] - this._itemPadding * 3 - this._scrollbarWidth);
|
|
}
|
|
|
|
// item text
|
|
const itemWidth = rowWidth * item.itemWidth;
|
|
const textStim = new TextStim(
|
|
Object.assign(textStimOption, {
|
|
text: item.itemText,
|
|
wrapWidth: itemWidth,
|
|
}),
|
|
);
|
|
textStim._relativePos = [this._itemPadding, stimulusOffset];
|
|
const textHeight = textStim.boundingBox.height;
|
|
this._visual.textStims.push(textStim);
|
|
|
|
// item response:
|
|
let responseStim = null;
|
|
let responseHeight = 0;
|
|
let compact;
|
|
let flip;
|
|
const responseWidth = rowWidth * item.responseWidth;
|
|
|
|
// CHOICE and RATING
|
|
if (item.type === Form.Types.CHOICE || item.type === Form.Types.RATING || item.type === Form.Types.SLIDER)
|
|
{
|
|
let sliderSize;
|
|
if (item.layout === Form.Layout.HORIZONTAL)
|
|
{
|
|
sliderSize = [responseWidth, sliderTickSize];
|
|
compact = true;
|
|
flip = false;
|
|
}
|
|
else
|
|
{
|
|
sliderSize = [sliderTickSize, (sliderTickSize * 1.5) * item.options.length];
|
|
compact = false;
|
|
flip = true;
|
|
}
|
|
|
|
let style, labels, ticks, granularity = 1;
|
|
if (item.type === Form.Types.CHOICE)
|
|
{
|
|
style = [Slider.Style.RATING, Slider.Style.RADIO];
|
|
labels = item.options;
|
|
ticks = []; // categorical
|
|
}
|
|
else if (item.type === Form.Types.SLIDER)
|
|
{
|
|
style = [Slider.Style.SLIDER];
|
|
labels = item.tickLabels;
|
|
ticks = item.ticks;
|
|
granularity = 0;
|
|
}
|
|
else
|
|
{
|
|
style = [Slider.Style.RATING];
|
|
labels = item.tickLabels;
|
|
ticks = item.ticks;
|
|
granularity = 1;
|
|
}
|
|
|
|
responseStim = new Slider(
|
|
Object.assign({}, sliderOption, {
|
|
granularity,
|
|
size: sliderSize,
|
|
style,
|
|
labels,
|
|
ticks,
|
|
compact,
|
|
flip,
|
|
}),
|
|
);
|
|
responseHeight = responseStim.boundingBox.height;
|
|
if (item.layout === Form.Layout.HORIZONTAL)
|
|
{
|
|
responseStim._relativePos = [
|
|
this._itemPadding * 2 + itemWidth + responseWidth / 2,
|
|
stimulusOffset,
|
|
// - Math.max(0, (textHeight - responseHeight) / 2) // (vertical centering)
|
|
];
|
|
}
|
|
else
|
|
{
|
|
responseStim._relativePos = [
|
|
this._itemPadding * 2 + itemWidth, // this._itemPadding + sliderTickSize,
|
|
stimulusOffset - responseHeight / 2 - textHeight - this._itemPadding,
|
|
];
|
|
|
|
// since rowHeight will be the max of itemHeight and responseHeight, we need to alter responseHeight
|
|
// to account for the fact that the response is below the item text:
|
|
responseHeight += textHeight + this._itemPadding;
|
|
}
|
|
}
|
|
// FREE TEXT
|
|
else if (item.type === Form.Types.FREE_TEXT)
|
|
{
|
|
responseStim = new TextBox(
|
|
Object.assign(textBoxOption, {
|
|
text: item.options,
|
|
size: [responseWidth, -1],
|
|
}),
|
|
);
|
|
responseHeight = responseStim.boundingBox.height;
|
|
responseStim._relativePos = [
|
|
this._itemPadding * 2 + itemWidth,
|
|
stimulusOffset,
|
|
];
|
|
}
|
|
|
|
this._visual.responseStims.push(responseStim);
|
|
|
|
const rowHeight = Math.max(textHeight, responseHeight);
|
|
this._visual.rowHeights.push(rowHeight);
|
|
|
|
stimulusOffset -= rowHeight + this._itemPadding;
|
|
}
|
|
this._visual.stimuliTotalHeight = stimulusOffset;
|
|
|
|
// scrollbar
|
|
// note: we add this Form as a dependent stimulus such that the Form is redrawn whenever
|
|
// the slider is updated
|
|
this._scrollbar = new Slider({
|
|
win: this._win,
|
|
name: "scrollbar",
|
|
units: this._units,
|
|
color: this.itemColor,
|
|
depth: this._depth + 1,
|
|
pos: [0, 0],
|
|
size: [this._scrollbarWidth, this._size[1]],
|
|
style: [Slider.Style.SLIDER],
|
|
ticks: [0, -this._visual.stimuliTotalHeight / this._size[1]],
|
|
dependentStims: [this],
|
|
});
|
|
this._prevScrollbarMarkerPos = 0;
|
|
this._scrollbar.setMarkerPos(this._prevScrollbarMarkerPos);
|
|
|
|
// estimate the bounding box:
|
|
this._estimateBoundingBox();
|
|
|
|
if (this._autoLog)
|
|
{
|
|
this._psychoJS.experimentLogger.exp(`Layout set for: ${this.name}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the form visual representation, if necessary.
|
|
*
|
|
* This estimate which stimuli are visible, and updates the decorations.
|
|
*
|
|
* @name module:visual.Slider#_updateIfNeeded
|
|
* @function
|
|
* @private
|
|
*/
|
|
_updateIfNeeded()
|
|
{
|
|
if (!this._needUpdate)
|
|
{
|
|
return;
|
|
}
|
|
this._needUpdate = false;
|
|
|
|
// calculate the edges of the form and various other sizes, in various units:
|
|
this._leftEdge = this._pos[0] - this._size[0] / 2.0;
|
|
this._rightEdge = this._pos[0] + this._size[0] / 2.0;
|
|
this._topEdge = this._pos[1] + this._size[1] / 2.0;
|
|
this._bottomEdge = this._pos[1] - this._size[1] / 2.0;
|
|
|
|
[this._leftEdge_px, this._topEdge_px] = util.to_px(
|
|
[this._leftEdge, this._topEdge],
|
|
this.units,
|
|
this.win,
|
|
true,
|
|
);
|
|
[this._rightEdge_px, this._bottomEdge_px] = util.to_px(
|
|
[this._rightEdge, this._bottomEdge],
|
|
this.units,
|
|
this.win,
|
|
true,
|
|
);
|
|
this._itemPadding_px = this._getLengthPix(this._itemPadding);
|
|
this._scrollbarWidth_px = this._getLengthPix(this._scrollbarWidth, true);
|
|
this._size_px = util.to_px(this._size, this.units, this.win, true);
|
|
|
|
// update the stimuli clip mask
|
|
// note: the clip mask is in screen coordinates
|
|
this._stimuliClipMask.clear();
|
|
this._stimuliClipMask.beginFill(0xFFFFFF);
|
|
this._stimuliClipMask.drawRect(
|
|
this._win._stimsContainer.position.x + this._leftEdge_px + 2,
|
|
this._win._stimsContainer.position.y + this._bottomEdge_px + 2,
|
|
this._size_px[0] - 4,
|
|
this._size_px[1] - 6,
|
|
);
|
|
this._stimuliClipMask.endFill();
|
|
|
|
// position the scrollbar and get the scrollbar offset, in form units:
|
|
this._scrollbar.setPos([this._rightEdge - this._scrollbarWidth / 2, this._pos[1]], false);
|
|
this._scrollbar.setOpacity(0.5);
|
|
this._scrollbarOffset = this._prevScrollbarMarkerPos * (this._visual.stimuliTotalHeight + this._size[1]) / (-this._visual.stimuliTotalHeight / this._size[1]);
|
|
|
|
// update decorations and stimuli:
|
|
this._updateVisibleStimuli();
|
|
this._updateDecorations();
|
|
}
|
|
|
|
/**
|
|
* Update the visible stimuli.
|
|
*
|
|
* @name module:visual.Form#_updateVisibleStimuli
|
|
* @function
|
|
* @private
|
|
*/
|
|
_updateVisibleStimuli()
|
|
{
|
|
for (let i = 0; i < this._items.length; ++i)
|
|
{
|
|
// a. item text
|
|
const textStim = this._visual.textStims[i];
|
|
const textStimPos = [
|
|
this._leftEdge + textStim._relativePos[0],
|
|
this._topEdge + textStim._relativePos[1] - this._scrollbarOffset,
|
|
];
|
|
textStim.setPos(textStimPos);
|
|
|
|
// b. response:
|
|
const responseStim = this._visual.responseStims[i];
|
|
if (responseStim)
|
|
{
|
|
const responseStimPos = [
|
|
this._leftEdge + responseStim._relativePos[0],
|
|
this._topEdge + responseStim._relativePos[1] - this._scrollbarOffset,
|
|
];
|
|
responseStim.setPos(responseStimPos);
|
|
}
|
|
|
|
// if the stimuli fall within the form area, we make them visible:
|
|
if (textStimPos[1] > this._bottomEdge && textStimPos[1] - this._visual.rowHeights[i] <= this._topEdge)
|
|
{
|
|
this._visual.visibles[i] = true;
|
|
}
|
|
// otherwise, we make them invisible:
|
|
else
|
|
{
|
|
// if the stimulus was previously visible, we need to hide it:
|
|
if (this._visual.visibles[i])
|
|
{
|
|
textStim.hide();
|
|
if (responseStim)
|
|
{
|
|
responseStim.hide();
|
|
}
|
|
}
|
|
|
|
this._visual.visibles[i] = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the form decorations (bounding box, lines between items, etc.)
|
|
*
|
|
* @name module:visual.Form#_updateDecorations
|
|
* @function
|
|
* @private
|
|
*/
|
|
_updateDecorations()
|
|
{
|
|
if (typeof this._pixi !== "undefined")
|
|
{
|
|
this._pixi.destroy(true);
|
|
}
|
|
|
|
this._pixi = new PIXI.Graphics();
|
|
this._pixi.scale.x = 1;
|
|
this._pixi.scale.y = 1;
|
|
this._pixi.rotation = 0;
|
|
this._pixi.position = to_pixiPoint(this.pos, this.units, this.win);
|
|
|
|
this._pixi.alpha = this._opacity;
|
|
this._pixi.zIndex = this._depth;
|
|
|
|
// apply the form clip mask (n.b., that is not the stimuli clip mask):
|
|
this._pixi.mask = this._clipMask;
|
|
|
|
// form background:
|
|
this._pixi.lineStyle(1, new Color(this.borderColor).int, this._opacity, 0.5);
|
|
// this._decorations.beginFill(this._barFillColor.int, this._opacity);
|
|
this._pixi.beginFill(new Color(this.fillColor).int);
|
|
this._pixi.drawRect(this._leftEdge_px, this._bottomEdge_px, this._size_px[0], this._size_px[1]);
|
|
// this._decorations.endFill();
|
|
this._pixi.endFill();
|
|
|
|
// item decorators:
|
|
this._decorations = new PIXI.Graphics();
|
|
this._pixi.addChild(this._decorations);
|
|
this._decorations.mask = this._stimuliClipMask;
|
|
this._decorations.lineStyle(1, new Color("gray").int, this._opacity, 0.5);
|
|
this._decorations.alpha = 0.5;
|
|
|
|
for (let i = 0; i < this._items.length; ++i)
|
|
{
|
|
if (this._visual.visibles[i])
|
|
{
|
|
const item = this._items[i];
|
|
// background for headings and descriptions:
|
|
if (item.type === Form.Types.HEADING || item.type === Form.Types.DESCRIPTION)
|
|
{
|
|
const textStim = this._visual.textStims[i];
|
|
const textStimPos = [
|
|
this._leftEdge + textStim._relativePos[0],
|
|
this._topEdge + textStim._relativePos[1] - this._scrollbarOffset,
|
|
];
|
|
const textStimPos_px = util.to_px(textStimPos, this._units, this._win);
|
|
this._decorations.beginFill(new Color("darkgray").int);
|
|
this._decorations.drawRect(
|
|
textStimPos_px[0] - this._itemPadding_px / 2,
|
|
textStimPos_px[1] + this._itemPadding_px / 2,
|
|
this._size_px[0] - this._itemPadding_px - this._scrollbarWidth_px,
|
|
-this._getLengthPix(this._visual.rowHeights[i]) - this._itemPadding_px,
|
|
);
|
|
this._decorations.endFill();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Form item types.
|
|
*
|
|
* @enum {Symbol}
|
|
* @readonly
|
|
* @public
|
|
*/
|
|
Form.Types = {
|
|
HEADING: Symbol.for("HEADING"),
|
|
DESCRIPTION: Symbol.for("DESCRIPTION"),
|
|
RATING: Symbol.for("RATING"),
|
|
SLIDER: Symbol.for("SLIDER"),
|
|
FREE_TEXT: Symbol.for("FREE_TEXT"),
|
|
CHOICE: Symbol.for("CHOICE"),
|
|
RADIO: Symbol.for("RADIO"),
|
|
};
|
|
|
|
/**
|
|
* Form item layout.
|
|
*
|
|
* @enum {Symbol}
|
|
* @readonly
|
|
* @public
|
|
*/
|
|
Form.Layout = {
|
|
HORIZONTAL: Symbol.for("HORIZONTAL"),
|
|
VERTICAL: Symbol.for("VERTICAL"),
|
|
};
|
|
|
|
/**
|
|
* Default form item.
|
|
*
|
|
* @readonly
|
|
* @private
|
|
*
|
|
*/
|
|
Form._defaultItems = {
|
|
"itemText": "Default question",
|
|
"type": "rating",
|
|
"options": "Yes, No",
|
|
"tickLabels": "",
|
|
"itemWidth": 0.7,
|
|
"itemColor": "white",
|
|
|
|
"responseWidth": 0.3,
|
|
"responseColor": "white",
|
|
|
|
"index": 0,
|
|
"layout": "horiz",
|
|
};
|
|
</code></pre>
|
|
</article>
|
|
</section>
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
<nav>
|
|
<h2><a href="index.html">Home</a></h2><h3>Modules</h3><ul><li><a href="module-core.html">core</a></li><li><a href="module-data.html">data</a></li><li><a href="module-sound.html">sound</a></li><li><a href="module-util.html">util</a></li><li><a href="module-visual.html">visual</a></li></ul><h3>Classes</h3><ul><li><a href="FaceDetector_FaceDetector.html">FaceDetector</a></li><li><a href="module.data.MultiStairHandler.html">MultiStairHandler</a></li><li><a href="module.data.QuestHandler.html">QuestHandler</a></li><li><a href="module-core.BuilderKeyResponse.html">BuilderKeyResponse</a></li><li><a href="module-core.EventManager.html">EventManager</a></li><li><a href="module-core.GUI.html">GUI</a></li><li><a href="module-core.Keyboard.html">Keyboard</a></li><li><a href="module-core.KeyPress.html">KeyPress</a></li><li><a href="module-core.Logger.html">Logger</a></li><li><a href="module-core.MinimalStim.html">MinimalStim</a></li><li><a href="module-core.Mouse.html">Mouse</a></li><li><a href="module-core.PsychoJS.html">PsychoJS</a></li><li><a href="module-core.ServerManager.html">ServerManager</a></li><li><a href="module-core.Window.html">Window</a></li><li><a href="module-data.ExperimentHandler.html">ExperimentHandler</a></li><li><a href="module-data.MultiStairHandler.html">MultiStairHandler</a></li><li><a href="module-data.QuestHandler.html">QuestHandler</a></li><li><a href="module-data.Shelf.html">Shelf</a></li><li><a href="module-data.TrialHandler.html">TrialHandler</a></li><li><a href="module-sound.AudioClip.html">AudioClip</a></li><li><a href="module-sound.AudioClipPlayer.html">AudioClipPlayer</a></li><li><a href="module-sound.Microphone.html">Microphone</a></li><li><a href="module-sound.Sound.html">Sound</a></li><li><a href="module-sound.TonePlayer.html">TonePlayer</a></li><li><a href="module-sound.TrackPlayer.html">TrackPlayer</a></li><li><a href="module-sound.Transcriber.html">Transcriber</a></li><li><a href="module-sound.Transcript.html">Transcript</a></li><li><a href="module-util.Clock.html">Clock</a></li><li><a href="module-util.Color.html">Color</a></li><li><a href="module-util.CountdownTimer.html">CountdownTimer</a></li><li><a href="module-util.EventEmitter.html">EventEmitter</a></li><li><a href="module-util.MixinBuilder.html">MixinBuilder</a></li><li><a href="module-util.MonotonicClock.html">MonotonicClock</a></li><li><a href="module-util.PsychObject.html">PsychObject</a></li><li><a href="module-util.Scheduler.html">Scheduler</a></li><li><a href="module-visual.ButtonStim.html">ButtonStim</a></li><li><a href="module-visual.Camera.html">Camera</a></li><li><a href="module-visual.FaceDetector.html">FaceDetector</a></li><li><a href="module-visual.Form.html">Form</a></li><li><a href="module-visual.GratingStim.html">GratingStim</a></li><li><a href="module-visual.ImageStim.html">ImageStim</a></li><li><a href="module-visual.MovieStim.html">MovieStim</a></li><li><a href="module-visual.Polygon.html">Polygon</a></li><li><a href="module-visual.Rect.html">Rect</a></li><li><a href="module-visual.ShapeStim.html">ShapeStim</a></li><li><a href="module-visual.Slider.html">Slider</a></li><li><a href="module-visual.TextBox.html">TextBox</a></li><li><a href="module-visual.TextStim.html">TextStim</a></li><li><a href="module-visual.VisualStim.html">VisualStim</a></li></ul><h3>Interfaces</h3><ul><li><a href="module-sound.SoundPlayer.html">SoundPlayer</a></li></ul><h3>Mixins</h3><ul><li><a href="module-core.WindowMixin.html">WindowMixin</a></li><li><a href="module-util.ColorMixin.html">ColorMixin</a></li></ul>
|
|
</nav>
|
|
|
|
<br class="clear">
|
|
|
|
<footer>
|
|
Documentation generated by <a href="https://github.com/jsdoc/jsdoc">JSDoc 3.6.7</a> on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
|
|
</footer>
|
|
|
|
<script> prettyPrint(); </script>
|
|
<script src="scripts/linenumber.js"> </script>
|
|
</body>
|
|
</html>
|