// returns width in pixels, where -1 is on the left and +1 on the right
function pixel_width(real_width)
{
return window.innerWidth*(real_width/2.0);
}
StructParam = {
horiz_space: 0.1,
width_cutoff: 800,
button_spacer: "7.5%"
};
// returns width of buttons for n-button horizontal fields (leaving space), as string in %
function horiz_width(n)
{
return Math.round(100.0*(1.0 - (n - 1)*StructParam.horiz_space)/n) + "%";
}
/*
pos should either be [x, y] or [x, y, "margin"]
-1 refers to left/bottom, 0 to the center, +1 to right/top of the browser window
[x, y]: the center of the element will be at that position, so that, e.g.,
for [-1, 0], the left half of the window will be invisible (outside the margins)
[x, y, "margin"]: here -1 means flush with the left/bottom edge of the window
*/
function set_position(elem, pos)
{
if(pos === undefined) pos = [0, 0];
elem.style.position = "absolute";
var h = "" + Math.round((1 + pos[0])*50) + "%";
var v = "" + Math.round((1 - pos[1])*50) + "%";
elem.style.left = h;
elem.style.top = v;
if(pos.length == 3 && pos[2] == "margin")
elem.style.transform = "translate(-" + h + ", -" + v + ")";
else
elem.style.transform = "translate(-50%, -50%)";
}
window.onresize = my_resize;
function my_resize()
{
var w = window.innerWidth;
var h = window.innerHeight;
var button_area = document.getElementById("GeneralDialogButtonArea");
if(button_area !== null) {
var button_class;
if(w > StructParam.width_cutoff) {
button_area.style.flexDirection = "row";
button_area.style.justifyContent = "space-between";
button_class = "h-button";
}
else {
button_area.style.flexDirection = "column";
button_area.style.justifyContent = "center";
button_class = "v-button";
}
var children = button_area.childNodes;
var buttons = [];
for(i = 0; i < children.length; i++) {
if(children[i].tagName == "BUTTON") {
children[i].className = button_class;
buttons.push(children[i]);
}
}
make_same_size(buttons, false, true);
}
}
/*
A simple message box, no buttons
text: the text
pos (default [0, 0]): for each dimension, -1 = left/bottom, 0 = center, +1 = right/top
*/
function Message(text, pos)
{
if(pos === undefined) pos = [0, 0];
// main box
this.message = document.createElement("div");
this.message.className = "outerbox";
set_position(this.message, pos);
// text area
var text_area = document.createElement("div");
text_area.innerHTML = text;
this.message.appendChild(text_area);
document.body.appendChild(this.message);
}
Message.prototype.remove = function()
{
document.body.removeChild(this.message);
};
/*
just a box of text, no buttons
to remove, call .remove()
if width is not specified, will use the standard width (from css)
*/
function TextBox(text, pos, width, height, center)
{
// main box
this.dialog = document.createElement("div");
this.dialog.className = "outerbox";
if(width) this.dialog.style.width = width;
if(height) this.dialog.style.height = height;
if(center === undefined) center = true;
this.dialog.innerHTML = center ? "
" + text + "" : text;
set_position(this.dialog, pos);
document.body.appendChild(this.dialog);
set_position(this.dialog, pos);
}
TextBox.prototype.remove = function()
{
document.body.removeChild(this.dialog);
}
/*
text: the text in tha main part of the dialog box, or a list for a sequence of dialogs
back_button (default false): whether to also have a back button
pos (default [0, 0]): for each dimension, -1 = left/bottom, 0 = center, +1 = right/top
button_text: if given, the text to show in the dialog button (or list, to put in next and back buttons)
---
When the user presses one of the buttons, .done becomes true
If there is a back button and the user pressed that button, in addition to .done, .back is also set to true
*/
function Dialog(text, back_button, pos, button_text, width)
{
if((typeof text) === "string")
this.texts = [text];
else
this.texts = text;
if(back_button === undefined) back_button = false;
if(pos === undefined) pos = [0, 0];
if((typeof button_text) === "string") button_text = [button_text, null];
this.done = false;
this.back = false;
this.index = 0; // points into this.texts array
// main box
this.dialog = document.createElement("div");
this.dialog.className = "outerbox";
if(width != undefined) this.dialog.style.width = width;
set_position(this.dialog, pos);
// text area at the top
this.text_area = document.createElement("div");
this.text_area.innerHTML = this.texts[this.index];
this.dialog.appendChild(this.text_area);
// button area at the bottom
var button_area = document.createElement("div");
button_area.className += " buttonarea";
// next button
var button_holder, button, that;
button = document.createElement("button");
button.type = "button";
var my_text = button_text && button_text[0] ? button_text[0] : TEXT("NEXT");
button.innerHtml = my_text;
button.appendChild(document.createTextNode(my_text));
if(!back_button) {
button.style.width = "100%";
}
else {
button.style.width = horiz_width(2);
button.style.float = "right";
}
that = this;
button.onclick = function() { that.next(); };
button_area.appendChild(button);
this.back_button = back_button;
if(back_button) {
button = document.createElement("button");
button.type = "button";
button.style.width = horiz_width(2);
button.style.float = "left";
var my_text = button_text && button_text[1] ? button_text[1] : TEXT("BACK");
button.innerHtml = my_text;
button.appendChild(document.createTextNode(my_text));
//button.value =
that = this;
button.onclick = function() { that.prev(); };
button_area.appendChild(button);
}
this.dialog.appendChild(button_area);
document.body.appendChild(this.dialog);
document.onkeydown = function(e) {
e = e || window.event;
switch(e.which || e.keyCode) {
case 13: // enter
case 32: // space
that.next();
break;
case 8: // back
if(that.back_button) {
that.prev();
}
break;
}
}
this.on_submit = null;
this.next_hook = null;
this.prev_hook = null;
}
Dialog.prototype.remove = function()
{
document.body.removeChild(this.dialog);
document.onkeydown = null;
};
Dialog.prototype.next = function()
{
if(this.index < this.texts.length - 1) {
this.index++;
this.text_area.innerHTML = this.texts[this.index];
//console.log("new text: " + this.texts[this.index]);
}
else {
this.done = true;
if(this.on_submit !== null) this.on_submit();
}
if(this.next_hook !== null) this.next_hook();
}
Dialog.prototype.prev = function()
{
if(this.index == 0) {
this.done = true;
this.back = true;
}
else {
this.index--;
this.text_area.innerHTML = this.texts[this.index];
}
if(this.prev_hook !== null) this.prev_hook();
}
/*
GeneralDialog is like Dialog, except an arbitrary list of buttons, which are displayed
on one line below the text
text: the text in tha main part of the dialog box
buttons: list of strings to display in each button
pos (default [0, 0]): for each dimension, -1 = left/bottom, 0 = center, +1 = right/top
---
When the user presses one of the buttons, .done becomes true
.button is set to the index of the button pressed (into the buttons array, 0-based)
*/
function GeneralDialog(text, buttons, pos, font_size)
{
if(pos === undefined || pos === null) pos = [0, 0];
if(font_size === undefined) font_size = null;
this.done = false;
// main box
this.dialog = document.createElement("div");
this.dialog.className = "outerbox";
this.dialog.className += " generalDialog";
set_position(this.dialog, pos);
// text area at the top
var text_area = document.createElement("div");
text_area.className += " generalDialogText";
text_area.innerHTML = text;
this.dialog.appendChild(text_area);
// button area at the bottom
var button_area = document.createElement("div");
button_area.id = "GeneralDialogButtonArea";
button_area.className += " generalbuttonarea";
// buttons
var B = [];
var that = this;
for(var i = 0; i < buttons.length; i++) {
var button = document.createElement("button");
button.type = "button";
button.innerHtml = buttons[i];
button.appendChild(document.createTextNode(buttons[i]));
//button.style.width = horiz_width(buttons.length);
button.style.visibility = "visible";//"hidden"; // until we uniformly set the height, below
if(font_size !== null) button.style.fontSize = font_size;
button.className = "h-button";
button.onclick = (function(ind) { return function() { that.click(ind); }; })(i);
button_area.appendChild(button);
B.push(button);
if(i < buttons.length - 1) {
var spacer = document.createElement("div");
spacer.style.width = StructParam.button_spacer;
button_area.appendChild(spacer);
}
}
this.dialog.appendChild(button_area);
document.body.appendChild(this.dialog);
//window.onresize();
my_resize();
}
GeneralDialog.prototype.click = function(n)
{
this.button = n;
this.done = true;
}
GeneralDialog.prototype.remove = function()
{
document.body.removeChild(this.dialog);
document.onkeydown = null;
};
/*
fields is a list, where each field can be:
- "label": static text field (used to be: text the same as label, no validation)
its ID will be "static_" + its text with all non-alphanumeric characters removed
- ["label", "text"]: no validation
- ["label", "text", "VAL"], where VAL can be:
- INT: value will be converted to int
- FLOAT: value will be converted to float
- NONEMPTY: any string other than ""
- ["label", "text", func], where func is a validation function, that receives a string and returns true (ok) or false (not)
- ["label", List] or ["label", "text", List]: drop-down list; in List, each element is either "label" or ["label", "text"]; value shouldn't be ""
label: what we call this field; after the dialog is done (.done = true), get the value using data["label"]
text: actual text that is displayed in the form
*/
function Form(fields, pos)
{
if(pos === undefined) pos = [0, 0];
// text fields
this.done = false;
this.form = document.createElement("div");
this.form.className = "outerbox";
this.fields = [];
var first_input = null;
for(var i = 0; i < fields.length; i++) {
var spec = fields[i];
var name, valid, options;
var type = "input";
var parameters = "";
var ok = false;
if(string_q(spec)) {
type = "static";
text = spec;
ok = true;
}
else if(array_q(spec)) {
if(spec.length == 2) {
if(string_q(spec[1])) {
name = spec[0];
text = spec[1];
valid = null;
ok = true;
}
else if(array_q(spec[1])) {
name = text = spec[0];
options = spec[1];
type = "select";
ok = true;
}
}
else if(spec.length == 3) {
if(array_q(spec[2])) {
name = spec[0];
text = spec[1];
options = spec[2];
type = "select";
ok = true;
}
else if(string_q(spec[2])) {
name = spec[0];
text = spec[1];
var valid_and_parameters = spec[2];
var valid_and_parameters_list = valid_and_parameters.split("#");
if(valid_and_parameters_list.length == 0) {
valid = "";
}
else {
valid = valid_and_parameters_list[0];
parameters = valid_and_parameters_list.slice(1);
}
ok = true;
}
}
}
if(!ok) continue;
var div = document.createElement("div");
div.className += " formComponent";
if(i == 0) div.className += " formComponentInitial";
if(i == fields.length - 1) div.className += " formComponentFinal";
if(type == "input") {
var multiline = array_contains_q(parameters, "multiline");
if(multiline) {
var input = document.createElement("textarea");
input.style.width = "100%";
input.rows = 5;
}
else {
var input = document.createElement("input");
input.type = "text";
}
input.name = input.id = name;
if(first_input === null) first_input = input;
var label = document.createElement("label");
label.id = name + "_label";
var text_node = document.createTextNode(text);
label.for = name;
label.appendChild(text_node);
div.appendChild(label);
div.appendChild(input);
}
else if(type == "select") {
var select = document.createElement("select");
select.required = true;
select.name = select.id = name;
var that = this;
select.onchange = function() { that.on_change(); }
var option = document.createElement("option");
option.value = "";
option.text = TEXT("SELECT");
option.selected = option.disabled = option.hidden = true;
select.appendChild(option);
for(var j = 0; j < options.length; j++) {
var opt = options[j];
var option = document.createElement("option");
if(string_q(opt)) {
option.value = option.text = opt;
}
else if(array_q(opt)) {
option.value = opt[0];
option.text = opt[1];
}
select.appendChild(option);
}
if(first_input === null) first_input = select;
var label = document.createElement("label");
label.id = name + "_label";
var text_node = document.createTextNode(text);
label.for = name;
label.appendChild(text_node);
div.appendChild(label);
div.appendChild(select);
}
else if(type == "static") {
div.appendChild(document.createTextNode(text));
div.id = "static_" + text.replace(/[^A-Za-z0-9]/g,'');
}
this.form.appendChild(div);
this.fields.push([type, name, valid, label]);
}
// submit button
var button_area = document.createElement("div");
button_area.className += " buttonarea";
var button = document.createElement("input");
button.type = "submit";
button.value = TEXT("SUBMIT");
var that = this;
button.onclick = function() { that.submit_callback(); }
button_area.appendChild(button);
this.form.appendChild(button_area);
set_position(this.form, pos);
document.body.appendChild(this.form);
document.onkeydown = function(e) {
e = e || window.event;
switch(e.which || e.keyCode) {
case 13: // enter
that.submit_callback();
}
}
if(first_input !== null) first_input.focus();
this.on_submit = null;
}
Form.prototype.submit_callback = function()
{
this.data = {};
var all_ok = true;
for(var i = 0; i < this.fields.length; i++) {
var f = this.fields[i];
var type = f[0], name = f[1], valid = f[2], label = f[3];
var elem = document.getElementById(name);
var ok = true, leave_me_alone = false;
if(type == "input") {
var val = elem.value.trim();
if(valid === null) {
}
else if(string_q(valid)) {
if(valid == "INT") {
if(val == "") ok = false;
else {
var v = parseInt(val);
if("" + v === val)
val = v;
else
ok = false;
}
}
else if(valid == "FLOAT") {
if(val == "") ok = false;
else {
if(isNaN(val)) ok = false;
else val = parseFloat(val);
}
}
else if(valid == "NONEMPTY") {
if(val == "") ok = false;
}
}
else { // if not string or null, then a validation function
ok = valid(val);
}
}
else if(type == "select") {
var val = elem.options[elem.selectedIndex].value;
if(val == "") ok = false;
}
else
leave_me_alone = true;
if(!leave_me_alone) {
if(ok) {
this.data[name] = val;
label.className = removeClass(label.className, "error");
}
else {
all_ok = false;
label.className += " error";
}
}
}
if(all_ok) {
this.done = true;
if(this.on_submit !== null) this.on_submit();
}
};
Form.prototype.on_change = function()
{
for(var i = 0; i < this.fields.length; i++) {
var f = this.fields[i];
var type = f[0], name = f[1], valid = f[2], label = f[3];
var elem = document.getElementById(name);
if(type == "select") {
var val = elem.options[elem.selectedIndex].value;
if(val != "")
label.className = removeClass(label.className, "error");
}
}
}
Form.prototype.remove = function()
{
document.body.removeChild(this.form);
document.onkeydown = null;
};
// a bitmap
function Image(src, width, pos)
{
if(pos === undefined) pos = [0, 0];
this.img = document.createElement("img");
this.img.src = src;
this.img.width = width;
set_position(this.img, pos);
document.body.append(this.img);
}
Image.prototype.remove = function()
{
document.body.removeChild(this.img);
};
// a square canvas with a context and with a reference frame running from -radius to +radius (def: radius = 1)
// if a position is specified, it is added to the window; otherwise it remains off-screen
function Picture(size, radius, pos, full_res)
{
if(radius === undefined) radius = 1;
//if(full_res === undefined) full_res = false;
if(full_res === undefined) full_res = true;
this.size = size;
this.pix_radius = size/2;
this.radius = radius;
this.canvas = document.createElement("canvas");
this.scale_factor = window.devicePixelRatio; // how many real pixels in each CSS pixel
if(full_res) {
this.canvas.width = this.canvas.height = this.scale_factor*size;
this.canvas.style.width = this.canvas.style.height = pix(size);
}
else {
this.canvas.width = this.canvas.height = this.size;
}
this.context = this.canvas.getContext("2d");
if(full_res) {
this.size *= this.scale_factor;
this.pix_radius *= this.scale_factor;
}
this.context.translate(this.pix_radius, this.pix_radius);
this.context.scale(this.pix_radius/this.radius, -this.pix_radius/this.radius);
if(pos !== undefined) {
this.place(pos);
}
this.clear();
}
Picture.prototype.clear = function()
{
this.context.clearRect(-this.radius, -this.radius, 2*this.radius, 2*this.radius);
};
Picture.prototype.set_visible = function(visible)
{
this.canvas.style.visibility = (visible ? "visible" : "hidden");
}
Picture.prototype.place = function(pos)
{
if(pos === undefined) pos = [0, 0];
set_position(this.canvas, pos);
document.body.appendChild(this.canvas);
};
Picture.prototype.draw_to = function(target)
{
/*
target.context.save();
target.context.setTransform(1, 0, 0, 1, 0, 0);
target.context.drawImage(this.canvas, target.pix_radius - this.pix_radius, target.pix_radius - this.pix_radius); target.context.restore();
*/
this.context.save()
target.context.save();
target.context.setTransform(1, 0, 0, 1, 0, 0);
this.context.setTransform(1, 0, 0, 1, 0, 0);
target.context.drawImage(this.canvas, 0, 0);
this.context.restore();
target.context.restore();
};
Picture.prototype.remove = function()
{
document.body.removeChild(this.canvas);
};
function Animation(n_frames, size, frame_rate, full_res)
{
if(frame_rate === undefined) frame_rate = 60;
this.n_frames = n_frames;
this.frames = new Array(this.n_frames);
this.repetitions = new Array(this.n_frames);
for(var i = 0; i < this.n_frames; i++) {
//this.frames[i] = new Picture(size);
this.frames[i] = new Picture(size, undefined, undefined, full_res);
this.repetitions[i] = 1;
}
this.frame_rate = frame_rate;
this.reset();
this.update_index();
};
Animation.prototype.reset = function()
{
this.done = (this.n_frames == 0 ? true : false);
this.frames_drawn = 0;
};
Animation.prototype.set_repetitions = function(n_frame, n_times)
{
if(n_frame >= 0 && n_frame < this.n_frames && n_times >= 0) {
this.repetitions[n_frame] = n_times;
this.update_index();
}
};
// which frame corresponds to which image
// if the second frame is repeated twice, then index will be [0, 1, 1, 2, ...]
Animation.prototype.update_index = function()
{
var i;
this.index = [];
for(i = 0; i < this.n_frames; i++) {
for(var j = 0; j < this.repetitions[i]; j++)
this.index.push(i);
}
this.drawn = Array(this.index.length);
for(i = 0; i < this.index.length; i++) this.drawn[i] = false;
};
/*
The displayed frame used to be based on the time from the initial frame,
rounded to the nearest frame. But there were too many rounding errors,
which led to video stuttering. So now I calculate the frame from the time
since the *previous* frame, which in most browsers is very close to 1000/60 ms,
or 2*1000/60, etc.
*/
Animation.prototype.draw = function(target)
{
if(this.done) return;
var now = get_time();
var draw = false;
if(this.frames_drawn == 0) {
draw = true;
this.ptr = 0;
}
else {
var dt = now - this.last_t;
var dp = Math.round(dt*this.frame_rate);
if(dp > 0) {
this.ptr += dp;
if(this.ptr < this.index.length) {
draw = true;
}
else {
target.clear();
this.done = true;
this.frame_fraction = this.frames_drawn/this.index.length;
this.frame_rate_effective = this.frame_fraction*this.frame_rate;
var str = "";
for(var i = 0; i < this.index.length; i++)
str += (this.drawn[i] ? 1 : 0) + " ";
//console.log("frame fraction: " + this.frame_fraction);
//console.log(str);
return;
}
}
}
if(draw) {
target.clear();
this.frames[this.index[this.ptr]].draw_to(target);
this.last_t = now;
this.frames_drawn++;
this.drawn[this.ptr] = true;
}
};
// draw a particular frame (0 = first, -1 = last)
Animation.prototype.draw_frame = function(target, n)
{
var my_n = n;
if(my_n < 0) my_n = this.frames.length + my_n;
if(my_n < 0) my_n = 0;
if(my_n > this.frames.length - 1) my_n = this.frames.length - 1;
target.clear();
this.frames[my_n].draw_to(target);
};
/*
// doesn't work
Animation.prototype.copy_frame = function(source, target)
{
var ctx_target = this.frames[target].canvas.getContext('2d');
ctx_target.drawImage(this.frames[source].canvas, 0, 0);
};
*/
// to free up memory
Animation.prototype.remove = function()
{
for(var i = 0; i < this.n_frames; i++)
this.frames[i] = null; // hopefully the GC will now clean it up
};
/*
specs is a list of specifications, for every icon/button to be displayed
each element is an object, containing the following keys:
icon: the (unrotated) icon image (Picture)
mask (optional): the mask, to highlight the image when cursor is near it
pos: position in window, [h, v], where h & v run from -1 (flush with left/bottom) to +1 (flush with right/top)
orient (optional): angle by which to rotate the stimulus (default 0)
distance_threshold is the distance from the cursor to the icon center below which an icon is highlighted (waiting for click), in units where 1 = half the width of the window
visibility: can be an array of boolean values, specifying which icons to display (all on by default)
remind_time: if present and not false, gives a delay after which a reminder text is shown
remind_info: class specifying parameters of reminder text (text: the text, pos: position, width, height)
*/
function ResponseSelector(specs, distance_threshold, mark_alpha, visibility, remind_time, remind_info)
{
if(visibility === undefined) visibility = true;
if(mark_alpha === undefined) mark_alpha = 0.25;
this.distance_threshold = distance_threshold;
this.mark_alpha = mark_alpha;
var i;
this.n_icons = specs.length;
this.specs = [];
for(i = 0; i < this.n_icons; i++) {
var spec = specs[i];
if(!("mask" in spec)) spec.mask = null;
if(!("orient" in spec)) spec.orient = 0;
this.specs.push(spec);
}
this.button_states = repeated_list("normal", this.n_icons); // can be normal, over, or down
this.done = false;
this.buttons = [];
var page_width = window.innerWidth, page_height = window.innerHeight;
var pix_per_unit = page_width/2.0;
var that = this;
for(i = 0; i < this.n_icons; i++) {
var spec = this.specs[i];
var b = document.createElement("canvas");
b.width = b.height = spec.icon.width;
set_position(b, spec.pos);
b.style.zIndex = -1000; // to not interfere with the stimulus
b.onclick = (function(ind) { return function() { that.manager(ind, "click"); }; })(i);
b.onmouseover = (function(ind) { return function() { that.manager(ind, "over"); }; })(i);
b.onmouseout = (function(ind) { return function() { that.manager(ind, "out"); }; })(i);
b.onmousedown = (function(ind) { return function() { that.manager(ind, "down"); }; })(i);
this.buttons.push(b);
document.body.appendChild(b);
}
window.onmousemove = (function(event) { that.mouse_move_manager(event); });
this.set_visibility(visibility);
if(remind_time !== false && remind_time !== undefined) {
this.remind_info = remind_info;
this.reminder = false;
this.reminder_timeout = setTimeout(function() { that.show_reminder(); },
1000*remind_time);
}
else {
this.reminder_timeout = this.reminder = false;
}
}
ResponseSelector.prototype.show_reminder = function()
{
this.reminder = new TextBox(this.remind_info.text,
this.remind_info.pos,
this.remind_info.width,
this.remind_info.height);
};
ResponseSelector.prototype.set_visibility = function(visibility)
{
if(visibility === undefined || visibility === true)
this.visibility = repeated_list(true, this.n_icons)
else
this.visibility = visibility;
this.draw_buttons();
};
ResponseSelector.prototype.clear_button = function(n)
{
var b = this.buttons[n];
var ctx = b.getContext("2d");
ctx.clearRect(0, 0, b.width, b.height);
};
ResponseSelector.prototype.draw_buttons = function()
{
for(var i = 0; i < this.n_icons; i++)
this.draw_button(i);
};
ResponseSelector.prototype.draw_button = function(n)
{
if(!this.visibility[n]) return;
var spec = this.specs[n];
var b = this.buttons[n];
var w = b.width, h = b.height;
var ctx = b.getContext("2d");
ctx.save();
ctx.clearRect(0, 0, w, h);
ctx.translate(+w/2, +h/2);
ctx.rotate(-spec.orient);
ctx.translate(-w/2, -h/2);
ctx.drawImage(spec.icon, 0, 0);
ctx.restore();
if(this.button_states[n] != "normal") {
ctx.save();
ctx.globalAlpha = this.mark_alpha;
ctx.translate(+w/2, +h/2);
ctx.rotate(-spec.orient);
ctx.translate(-w/2, -h/2);
ctx.drawImage(spec.mask, 0, 0);
ctx.restore();
}
};
ResponseSelector.prototype.manager = function(button, event)
{
if(event == "click") {
this.resp = button;
this.done = true;
}
else if(event == "over") {
this.button_states[button] = "over";
this.draw_button(button);
}
else if(event == "out") {
this.button_states[button] = "normal";
this.draw_button(button);
}
else if(event == "down") {
this.button_states[button] = "down";
this.draw_button(button);
}
};
ResponseSelector.prototype.mouse_move_manager = function(event)
{
var W = window.innerWidth, H = window.innerHeight;
var pos = [2*event.clientX/W - 1, 1 - 2*event.clientY/H];
var smallest_distance = Number.MAX_VALUE;
var closest_icon = null;
for(var i = 0; i < this.n_icons; i++) {
var d = dist(pos, this.specs[i].pos);
if(d < smallest_distance) {
smallest_distance = d;
closest_icon = i;
}
}
if(closest_icon !== null) {
var change = false;
for(var i = 0; i < this.n_icons; i++) {
var new_state = (i == closest_icon && smallest_distance < this.distance_threshold ? "over" : "normal");
if(this.button_states[i] != new_state) change = true;
this.button_states[i] = new_state;
}
if(change) this.draw_buttons();
}
};
ResponseSelector.prototype.remove = function()
{
for(var i = 0; i < this.n_icons; i++)
document.body.removeChild(this.buttons[i]);
if(this.reminder_timeout)
clearTimeout(this.reminder_timeout);
if(this.reminder)
this.reminder.remove();
};
// size is in 'real' units, with -1 on the left and +1 on the right
function Cursor(size)
{
this.size = pixel_width(size);
this.canvas = document.createElement("canvas");
this.canvas.width = this.canvas.height = this.size;
this.canvas.style.position = "absolute";
this.canvas.style.transform = "translate(-50%, -50%)";
this.canvas.style.visibility = "hidden";
document.body.appendChild(this.canvas);
}
// draw TO the cursor itself
Cursor.prototype.begin_drawing = function()
{
var ctx = this.canvas.getContext("2d");
ctx.translate(this.size/2, this.size/2);
ctx.scale(this.size/2, -this.size/2);
return ctx;
};
// show the cursor at given position
// if pixels if false (default), uses the [-1, +1] ref. frame
// otherwise uses absolute pixels
Cursor.prototype.show = function(pos, pixels)
{
if(pixels === undefined) pixels = false;
if(pixels) {
this.canvas.style.left = "" + Math.round(pos[0]) + "px";
this.canvas.style.top = "" + Math.round(pos[1]) + "px";
}
else {
this.canvas.style.left = "" + ((1 + pos[0])*50) + "%";
this.canvas.style.top = "" + ((1 - pos[1])*50) + "%";
}
this.canvas.style.visibility = "visible";
};
Cursor.prototype.remove = function()
{
document.body.removeChild(this.canvas);
}
/*
Like ResponseSelector, but for our own, privately managed and reposionable cursor
3 extra arguments:
- init_cursor_pos: [0, 0] for center of window
- cursor_size: in logical pixels
- create_cursor_f: a function that takes a 2d canvas context and draws the shape of
the cursor (in a square area where both coordinates go from -1 to +1)
Subsequent arguments are the same as for ResponseSelector
*/
function ResponseSelectorCursor(init_cursor_pos, cursor_size, create_cursor_f,
specs, distance_threshold, mark_alpha, visibility)
{
if(mark_alpha === undefined) mark_alpha = 0.25;
this.distance_threshold = distance_threshold;
this.mark_alpha = mark_alpha;
var i;
this.n_icons = specs.length;
this.specs = [];
for(i = 0; i < this.n_icons; i++) {
var spec = specs[i];
if(!("mask" in spec)) spec.mask = null;
if(!("orient" in spec)) spec.orient = 0;
this.specs.push(spec);
}
this.button_states = repeated_list("normal", this.n_icons); // can be normal, over, or down
this.button_over = null; // the highlighted button or null if none
this.done = false;
this.buttons = [];
var page_width = window.innerWidth, page_height = window.innerHeight;
var pix_per_unit = page_width/2.0;
var that = this;
for(i = 0; i < this.n_icons; i++) {
var spec = this.specs[i];
var b = document.createElement("canvas");
b.width = b.height = spec.icon.width;
set_position(b, spec.pos);
b.style.zIndex = -1000; // to not interfere with the stimulus
this.buttons.push(b);
document.body.appendChild(b);
}
window.onclick = (function(event) { that.mouse_click_manager(event); });
window.onmousemove = (function(event) { that.mouse_move_manager(event); });
this.set_visibility(visibility);
document.documentElement.requestPointerLock();
this.cursor_pos = init_cursor_pos;
this.cursor = new Cursor(cursor_size);
var ctx = this.cursor.begin_drawing();
create_cursor_f(ctx);
this.cursor.show(this.cursor_pos);
}
ResponseSelectorCursor.prototype.set_visibility = function(visibility)
{
if(visibility === undefined || visibility === true)
this.visibility = repeated_list(true, this.n_icons)
else
this.visibility = visibility;
this.draw_buttons();
}
ResponseSelectorCursor.prototype.clear_button = function(n)
{
var b = this.buttons[n];
var ctx = b.getContext("2d");
ctx.clearRect(0, 0, b.width, b.height);
};
ResponseSelectorCursor.prototype.draw_buttons = function()
{
for(var i = 0; i < this.n_icons; i++)
this.draw_button(i);
};
ResponseSelectorCursor.prototype.draw_button = function(n)
{
if(!this.visibility[n]) return;
var spec = this.specs[n];
var b = this.buttons[n];
var w = b.width, h = b.height;
var ctx = b.getContext("2d");
ctx.save();
ctx.clearRect(0, 0, w, h);
ctx.translate(+w/2, +h/2);
ctx.rotate(-spec.orient);
ctx.translate(-w/2, -h/2);
ctx.drawImage(spec.icon, 0, 0);
ctx.restore();
if(this.button_states[n] != "normal") {
ctx.save();
ctx.globalAlpha = this.mark_alpha;
ctx.translate(+w/2, +h/2);
ctx.rotate(-spec.orient);
ctx.translate(-w/2, -h/2);
ctx.drawImage(spec.mask, 0, 0);
ctx.restore();
}
};
ResponseSelectorCursor.prototype.mouse_click_manager = function(event)
{
if(this.button_over !== null) {
this.resp = this.button_over;
this.done = true;
}
};
ResponseSelectorCursor.prototype.mouse_move_manager = function(event)
{
var W = window.innerWidth, H = window.innerHeight;
this.cursor_pos[0] += event.movementX*2/W;
this.cursor_pos[1] -= event.movementY*2/H;
this.cursor.show(this.cursor_pos);
var smallest_distance = Number.MAX_VALUE;
var closest_icon = null;
for(var i = 0; i < this.n_icons; i++) {
var d = dist(this.cursor_pos, this.specs[i].pos);
if(d < smallest_distance) {
smallest_distance = d;
closest_icon = i;
}
}
if(closest_icon !== null) {
if(smallest_distance < this.distance_threshold)
this.button_over = closest_icon;
else
this.button_over = null;
var change = false;
for(var i = 0; i < this.n_icons; i++) {
var new_state = (i == closest_icon && smallest_distance < this.distance_threshold ? "over" : "normal");
if(this.button_states[i] != new_state) change = true;
this.button_states[i] = new_state;
}
if(change) this.draw_buttons();
}
else
this.button_over = null;
};
ResponseSelectorCursor.prototype.remove = function()
{
for(var i = 0; i < this.n_icons; i++)
document.body.removeChild(this.buttons[i]);
this.cursor.remove();
document.exitPointerLock();
};
/*
A target that needs to be moused-over or clicked on
- pos: [0, 0] for window center
- size: square, in units where 2 is screen width
- draw_f: a function that takes a 2d canvas context and draws
the target (in a square area where both coordinates go from
-1 to +1)
- trigger_type: "touch" or "click"
- remind_time: if present and not false, gives a delay after which a reminder text is shown
- remind_info: class specifying parameters of reminder text (text, pos, width, height)
*/
function MouseTarget(pos, size, draw_f, trigger_type, remind_time, remind_info)
{
this.size = pixel_width(size);
this.canvas = document.createElement("canvas");
this.canvas.width = this.canvas.height = this.size;
this.canvas.style.position = "absolute";
this.canvas.style.transform = "translate(-50%, -50%)";
this.canvas.style.visibility = "visible";
set_position(this.canvas, pos);
var ctx = this.canvas.getContext("2d");
ctx.translate(this.size/2, this.size/2);
ctx.scale(this.size/2, -this.size/2);
draw_f(ctx);
this.trigger_type = trigger_type;
var that = this;
this.canvas.onclick = (function(event) { that.onclick_manager(event); });
this.canvas.onmouseover = (function(event) { that.onmouseover_manager(event); });
if(remind_time !== false && remind_time !== undefined) {
this.remind_info = remind_info;
this.reminder = false;
this.reminder_timeout = setTimeout(function() { that.show_reminder(); },
1000*remind_time);
}
else {
this.reminder_timeout = this.reminder = false;
}
document.body.appendChild(this.canvas);
this.done = false;
}
MouseTarget.prototype.show_reminder = function()
{
this.reminder = new TextBox(this.remind_info.text,
this.remind_info.pos,
this.remind_info.width,
this.remind_info.height);
};
MouseTarget.prototype.onclick_manager = function(event)
{
if(this.trigger_type == "click") this.done = true;
};
MouseTarget.prototype.onmouseover_manager = function(event)
{
if(this.trigger_type == "touch") this.done = true;
};
MouseTarget.prototype.remove = function()
{
document.body.removeChild(this.canvas);
if(this.reminder_timeout)
clearTimeout(this.reminder_timeout);
if(this.reminder)
this.reminder.remove();
}
/*
urls can be a single url (string), or an array of url strings
note that the urls can be relative (e.g., "images/icon.png")
done_func is a function to call when done (optional)
timeout is a duration after which, if the images are still not loaded, we'll call timeout_func
---
after images are loaded, they are available at ImageLoader.images[name],
where name is the url root (images/foo.png --> foo,
http://wexler.free.fr/moo.jpg --> moo, etc.), if root_keys is true
---
Strange thing on 2/9/19: the images in the array .images[] are instantiations of my Image class.
But this doesn't work, and doesn't actually make sense, because an Image is a visible image on a page.
So I changed it so that .images[] are actually just naked DOM images, i.e., what is returned by
document.createElement("img").
*/
function ImageLoader(urls, done_func, timeout, timeout_func, root_keys)
{
var that = this;
this.done_func = done_func;
this.timeout_func = timeout_func;
if((typeof urls) === "string") urls = [urls];
var url, key;
this.images_to_load = urls.length;
this.images_loaded = 0;
this.done = false;
this.timeout = false;
this.images = {};
for(var i = 0; i < urls.length; i++) {
url = urls[i];
key = root_keys ? get_url_root(url) : url;
//console.log('now loading "' + url + '", key=' + key);
/*
this.images[key] = new Image();
this.images[key].src = url;
*/
/*
this.images[key] = new Image(url);
this.images[key].onload = function() { that.on_load(); }
*/
this.images[key] = document.createElement("img");
this.images[key].src = url;
this.images[key].onload = function() { that.on_load(); };
}
if(timeout !== undefined) // set timeout
setTimeout(function() { that.on_timeout(); }, 1000*timeout);
}
// this function is called every time one image is loaded
ImageLoader.prototype.on_load = function()
{
this.images_loaded++;
if(this.images_loaded >= this.images_to_load) { // we're done!
this.done = true;
if(this.done_func) this.done_func(); // call the callback, if one exists
}
};
// this function is called after a timeout period, if one exists
// if, at this point, all images are still not loaded, then will call timeout_func
ImageLoader.prototype.on_timeout = function()
{
if(!this.done) {
this.timeout = true;
if(this.timeout_func) this.timeout_func();
}
};
function ProgressBar(length, width, edge_thickness, pos, border_col, bg_col)
{
if(pos === undefined) pos = [0, 0];
if(border_col === undefined) border_col = "black";
if(bg_col === undefined) bg_col = "white";
this.width = length;
this.height = width;
this.thick = edge_thickness;
this.border_col = border_col;
this.bg_col = bg_col;
this.canvas = document.createElement("canvas");
this.canvas.width = this.width;
this.canvas.height = this.height;
set_position(this.canvas, pos);
document.body.appendChild(this.canvas);
this.context = this.canvas.getContext("2d");
this.draw(0, false);
}
ProgressBar.prototype.draw = function(x, col)
{
this.context.fillStyle = this.border_col;
this.context.fillRect(0, 0, this.width, this.height);
var inner_width = this.width - 2*this.thick, inner_height = this.height - 2*this.thick;
this.context.fillStyle = this.bg_col;
this.context.fillRect(this.thick, this.thick, inner_width, inner_height);
if(x < 0) x = 0;
if(x > 1) x = 1;
var prog_len = Math.round(x*inner_width);
this.context.fillStyle = col;
this.context.fillRect(this.thick, this.thick, prog_len, inner_height);
};
ProgressBar.prototype.remove = function()
{
document.body.removeChild(this.canvas);
};
function HttpPost()
{
this.req = "";
}
HttpPost.prototype.add_param = function(param, value)
{
if(this.req != "") this.req += "&";
this.req += param;
if(value !== undefined && value !== null) {
this.req += "=";
this.req += encodeURIComponent(value);
}
};
// encapsulates XMLHttpRequest
// will send request to server
// when done is true, the return value has been received
// ok is set to true or false depending on the status
// response gives the response value (when done and ok are true)
// data should be an object with key->val pairs (or key->null
// to just specify a post parameter with no value
// retries (default 1) should be the number of times to retry on error or timeout
// timeout is the timeout delay, in msec
function ServerRequest(server, data, retries, timeout)
{
this.server = server;
var post = new HttpPost();
//console.log(Object.keys(data));
for(var key in data) {
//console.log(key);
//console.log(key in data);
post.add_param(key, data[key]);
}
this.req = post.req;
//console.log(this.req);
this.retries = (typeof retries != "undefined") ? retries : 1;
this.timeout = (typeof timeout != "undefined") ? timeout : 10000; // default 10 sec timeout
this.n_tries = 0; // augmented each time we retry
this.send();
}
ServerRequest.prototype.send = function()
{
this.request = new XMLHttpRequest();
this.request.open("POST", this.server, true); // asynchronous
this.request.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
that = this;
this.request.onreadystatechange = function() { that.on_state_change(); };
this.request.ontimeout = function() { that.failed(); };
this.done = this.ok = false;
this.n_tries++;
this.request.send(this.req);
}
ServerRequest.prototype.on_state_change = function()
{
if(this.request.readyState == 4) {
if(this.request.status == 200) {
this.done = true;
this.ok = true;
this.response = this.request.responseText;
}
else this.failed();
}
}
// either return status is not ok (200), or timed out
ServerRequest.prototype.failed = function()
{
if(this.retries == 0 || this.n_tries < this.retries)
this.send();
else {
this.done = true;
this.ok = false;
}
}
ServerRequest.prototype.stop = function()
{
this.request.abort();
}