From 515ea401c98fed4cc2c1ad7b5a49be421b7f801c Mon Sep 17 00:00:00 2001 From: zh1peng Date: Mon, 27 Jul 2026 01:07:47 +0800 Subject: [PATCH] Add random-dot motion web stimulus --- src/core/types.ts | 15 ++ src/jspsych/PsyflowStagePlugin.ts | 149 ++++++++++++++- .../unit/random-dot-motion-confidence.test.ts | 180 ++++++++++++++++++ 3 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 tests/unit/random-dot-motion-confidence.test.ts diff --git a/src/core/types.ts b/src/core/types.ts index 023c658..b5f284f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -38,6 +38,7 @@ export type StimSpec = | ShapeStimSpec | ImageStimSpec | MovieStimSpec + | RandomDotMotionStimSpec | SoundStimSpec | SpeechStimSpec; @@ -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; diff --git a/src/jspsych/PsyflowStagePlugin.ts b/src/jspsych/PsyflowStagePlugin.ts index 53f8307..c2dc151 100644 --- a/src/jspsych/PsyflowStagePlugin.ts +++ b/src/jspsych/PsyflowStagePlugin.ts @@ -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"); @@ -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; } @@ -651,9 +787,15 @@ export class PsyflowStagePlugin implements JsPsychPlugin { 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; @@ -807,6 +949,9 @@ export class PsyflowStagePlugin implements JsPsychPlugin { 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); diff --git a/tests/unit/random-dot-motion-confidence.test.ts b/tests/unit/random-dot-motion-confidence.test.ts new file mode 100644 index 0000000..f261a03 --- /dev/null +++ b/tests/unit/random-dot-motion-confidence.test.ts @@ -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; + 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" + }); + }); +});