Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export type StimSpec =
| ShapeStimSpec
| ImageStimSpec
| MovieStimSpec
| RandomDotMotionStimSpec
| SoundStimSpec
| SpeechStimSpec;

Expand Down Expand Up @@ -123,6 +124,20 @@ export interface MovieStimSpec extends BaseStimSpec {
volume?: number;
}

export interface RandomDotMotionStimSpec extends BaseStimSpec {
type: "random_dot_motion";
direction: "left" | "right";
coherence: number;
seed?: number;
n_dots?: number;
dot_size_deg?: number;
dot_life_frames?: number;
speed_deg_s?: number;
aperture_diameter_deg?: number;
refresh_hz?: number;
dot_color?: string;
}

export interface SoundStimSpec extends BaseStimSpec {
type: "sound";
file: string;
Expand Down
149 changes: 147 additions & 2 deletions src/jspsych/PsyflowStagePlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,23 @@ function applyBaseStimStyle(element: HTMLElement, spec: StimSpec, stageRoot: HTM
}
}

function renderStimulus(stageRoot: HTMLElement, spec: StimSpec, movieSink: HTMLVideoElement[]): void {
function seededRandom(seed: number): () => number {
let state = Math.trunc(seed) >>> 0;
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}

function renderStimulus(
stageRoot: HTMLElement,
spec: StimSpec,
movieSink: HTMLVideoElement[],
animationCleanupSink: Array<() => void>
): void {
switch (spec.type) {
case "text": {
const element = document.createElement("div");
Expand Down Expand Up @@ -531,6 +547,126 @@ function renderStimulus(stageRoot: HTMLElement, spec: StimSpec, movieSink: HTMLV
}
return;
}
case "random_dot_motion": {
const canvas = document.createElement("canvas");
canvas.className = "psyflow-stage-stim psyflow-stage-random-dot-motion";
applyBaseStimStyle(canvas, spec, stageRoot);
const apertureDiameter = Math.max(
0.1,
Number(spec.aperture_diameter_deg ?? 6)
);
canvas.style.width = toLength(
apertureDiameter,
spec.units ?? "deg",
apertureDiameter,
stageRoot
);
canvas.style.height = canvas.style.width;
canvas.style.borderRadius = "50%";
canvas.style.display = "block";
stageRoot.appendChild(canvas);

const context = canvas.getContext("2d");
if (!context) {
return;
}
const pixelRatio = Math.max(1, window.devicePixelRatio || 1);
const cssSize = Math.max(
64,
canvas.getBoundingClientRect().width || apertureDiameter * 32
);
canvas.width = Math.round(cssSize * pixelRatio);
canvas.height = Math.round(cssSize * pixelRatio);

const random = seededRandom(Number(spec.seed ?? 0));
const nDots = Math.max(1, Math.trunc(Number(spec.n_dots ?? 150)));
const radiusDeg = apertureDiameter / 2;
const coherence = Math.min(1, Math.max(0, Number(spec.coherence ?? 0)));
const lifeFrames = Math.max(
1,
Math.trunc(Number(spec.dot_life_frames ?? 4))
);
const speedDegS = Math.max(0, Number(spec.speed_deg_s ?? 6));
const refreshHz = Math.max(1, Number(spec.refresh_hz ?? 60));
const signalAngle = spec.direction === "left" ? Math.PI : 0;
const positions = new Float64Array(nDots * 2);
const ages = new Int32Array(nDots);
const signalMask = new Uint8Array(nDots);

const respawn = (index: number) => {
const radius = radiusDeg * Math.sqrt(random());
const angle = random() * Math.PI * 2;
positions[index * 2] = radius * Math.cos(angle);
positions[index * 2 + 1] = radius * Math.sin(angle);
ages[index] = lifeFrames;
};
for (let index = 0; index < nDots; index += 1) {
respawn(index);
ages[index] = 1 + Math.floor(random() * lifeFrames);
}
const shuffled = Array.from({ length: nDots }, (_, index) => index);
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swap = Math.floor(random() * (index + 1));
[shuffled[index], shuffled[swap]] = [shuffled[swap], shuffled[index]];
}
const signalCount = Math.round(nDots * coherence);
for (let index = 0; index < signalCount; index += 1) {
signalMask[shuffled[index]] = 1;
}

let stopped = false;
let frameId = 0;
let lastTimestamp: number | null = null;
const renderFrame = (timestamp: number) => {
if (stopped) {
return;
}
const elapsedSeconds =
lastTimestamp == null
? 1 / refreshHz
: Math.min(0.05, Math.max(0, (timestamp - lastTimestamp) / 1000));
lastTimestamp = timestamp;
const step = speedDegS * elapsedSeconds;

context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = normalizeCssColor(spec.dot_color ?? spec.color) ?? "#ffffff";
const dotRadiusPx = Math.max(
0.75 * pixelRatio,
(Number(spec.dot_size_deg ?? 0.1) / apertureDiameter) *
canvas.width *
0.5
);
for (let index = 0; index < nDots; index += 1) {
const angle = signalMask[index]
? signalAngle
: random() * Math.PI * 2;
positions[index * 2] += step * Math.cos(angle);
positions[index * 2 + 1] += step * Math.sin(angle);
ages[index] -= 1;
const x = positions[index * 2];
const y = positions[index * 2 + 1];
if (ages[index] <= 0 || x * x + y * y > radiusDeg * radiusDeg) {
respawn(index);
}
const px =
canvas.width / 2 +
(positions[index * 2] / apertureDiameter) * canvas.width;
const py =
canvas.height / 2 -
(positions[index * 2 + 1] / apertureDiameter) * canvas.height;
context.beginPath();
context.arc(px, py, dotRadiusPx, 0, Math.PI * 2);
context.fill();
}
frameId = window.requestAnimationFrame(renderFrame);
};
frameId = window.requestAnimationFrame(renderFrame);
animationCleanupSink.push(() => {
stopped = true;
window.cancelAnimationFrame(frameId);
});
return;
}
case "sound": {
return;
}
Expand Down Expand Up @@ -651,9 +787,15 @@ export class PsyflowStagePlugin implements JsPsychPlugin<Info> {
display_element.appendChild(stageRoot);
display_element.focus();
const activeMovies: HTMLVideoElement[] = [];
const activeAnimationCleanups: Array<() => void> = [];
for (const stim of execution.stimuli) {
const childCount = stageRoot.children.length;
renderStimulus(stageRoot, stim.spec, activeMovies);
renderStimulus(
stageRoot,
stim.spec,
activeMovies,
activeAnimationCleanups
);
const rendered = stageRoot.children.item(childCount);
if (rendered instanceof HTMLElement && stim.stim_id) {
rendered.dataset.psyflowStimId = stim.stim_id;
Expand Down Expand Up @@ -807,6 +949,9 @@ export class PsyflowStagePlugin implements JsPsychPlugin<Info> {
movie.removeAttribute("src");
movie.load();
}
for (const stopAnimation of activeAnimationCleanups) {
stopAnimation();
}
window.removeEventListener("keydown", keydownListener, true);
document.removeEventListener("keydown", keydownListener, true);
display_element.removeEventListener("keydown", keydownListener, true);
Expand Down
180 changes: 180 additions & 0 deletions tests/unit/random-dot-motion-confidence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";

import {
StimBank,
TaskSettings,
TrialBuilder,
type RuntimeView,
type TrialSnapshot
} from "../../src";
import { run_trial } from "../../../H000076-random-dot-motion-confidence-task/src/run_trial";
import {
canonicalConfidence,
decodeCondition
} from "../../../H000076-random-dot-motion-confidence-task/src/utils";

const settings = () => {
const value = TaskSettings.from_dict({
total_blocks: 1,
total_trials: 8,
trial_per_block: 8,
conditions: [
"left_c12_lh",
"left_c12_hl",
"left_c28_lh",
"left_c28_hl",
"right_c12_lh",
"right_c12_hl",
"right_c28_lh",
"right_c28_hl"
],
direction_keys: { left: "f", right: "j" },
confidence_keys: ["1", "2", "3", "4", "5", "6", "7", "8", "9"],
confidence_low_label: "猜测",
confidence_high_label: "非常确定",
overall_seed: 76076,
fixation_duration: 0.5,
motion_response_window: 1.2,
post_decision_delay: [1.5, 4],
confidence_response_window: 3,
direction_timeout_duration: 0.75,
iti_duration: 0.5
}) as TaskSettings & Record<string, unknown>;
value.triggers = {
fixation: 20,
motion_left_c12: 31,
motion_right_c12: 32,
motion_left_c28: 33,
motion_right_c28: 34,
direction_left_response: 40,
direction_right_response: 41,
direction_timeout: 42,
post_decision_blank: 50,
confidence_low_to_high: 61,
confidence_high_to_low: 62,
confidence_response: 63,
confidence_timeout: 64,
iti: 70
};
return value;
};

const bank = () =>
new StimBank({
fixation: { type: "text", text: "+" },
random_dot_motion: {
type: "random_dot_motion",
direction: "left",
coherence: 0.12,
n_dots: 150,
dot_size_deg: 0.1,
dot_life_frames: 4,
speed_deg_s: 6,
aperture_diameter_deg: 6
},
blank: { type: "rect", width: 40, height: 30 },
direction_timeout_message: { type: "text", text: "反应太慢" },
confidence_prompt: { type: "text", text: "信心?" },
confidence_scale_line: { type: "rect", width: 14, height: 0.08 },
confidence_numbers: { type: "text", text: "1 2 3 4 5 6 7 8 9" },
confidence_left_label: { type: "text", text: "猜测" },
confidence_right_label: { type: "text", text: "非常确定" }
});

describe("H000076 random-dot motion + confidence", () => {
it("decodes all condition factors and canonicalizes reversed confidence", () => {
expect(decodeCondition("right_c28_hl")).toMatchObject({
direction: "right",
coherence: 0.28,
coherence_percent: 28,
scale_orientation: "hl"
});
expect(canonicalConfidence("8", "lh")).toBe(8);
expect(canonicalConfidence("2", "hl")).toBe(8);
});

it("compiles the canonical phase sequence and random-dot parameters", () => {
const trial = new TrialBuilder({
trial_id: 1,
block_id: "block_1",
trial_index: 0,
condition: "left_c12_lh"
});
run_trial(trial, "left_c12_lh", {
settings: settings(),
stimBank: bank(),
block_id: "block_1",
block_idx: 0
});
const compiled = trial.build();
expect(compiled.units.map((unit) => unit.unit_label)).toEqual([
"fixation",
"motion_decision",
"post_decision_blank",
"timeout_message",
"confidence",
"iti"
]);
expect(compiled.units[1]).toMatchObject({
op: "capture_response",
duration: 1.2,
onset_trigger: 31
});
expect(compiled.units[1].stim_refs[0]).toMatchObject({
type: "random_dot_motion",
direction: "left",
coherence: 0.12,
n_dots: 150,
dot_life_frames: 4,
speed_deg_s: 6,
aperture_diameter_deg: 6
});
});

it("exports direction and confidence outcomes with canonical meaning", () => {
const trial = new TrialBuilder({
trial_id: 2,
block_id: "block_1",
trial_index: 1,
condition: "right_c28_hl"
});
run_trial(trial, "right_c28_hl", {
settings: settings(),
stimBank: bank(),
block_id: "block_1",
block_idx: 0
});
const compiled = trial.build();
const trialState = { ...compiled.trial_state };
const units = {
motion_decision: { response: "j", rt: 0.42 },
confidence: { response: "2", rt: 0.55 }
};
const snapshot = {
trial_id: 2,
block_id: "block_1",
trial_index: 1,
condition: "right_c28_hl",
units,
trial_state: trialState
} satisfies TrialSnapshot;
const runtime = {
getReducedRows: () => [],
sumReducedField: () => 0
} satisfies RuntimeView;
compiled.finalizers[0](snapshot, runtime, {
setTrialState: (key, value) => {
trialState[key] = value;
},
getUnitState: (unit, key) => units[unit as keyof typeof units]?.[key as never]
});
expect(trialState).toMatchObject({
direction_response: "j",
direction_correct: true,
confidence_position: 2,
confidence_rating: 8,
confidence_timed_out: false,
outcome: "correct"
});
});
});