An easy-to-use platform for EEG experimentation in the classroom
1import React, { Component } from 'react';
2import { Button } from '../ui/button';
3import {
4 Table,
5 TableHeader,
6 TableBody,
7 TableRow,
8 TableHead,
9 TableCell,
10} from '../ui/table';
11import { isString } from 'lodash';
12
13import { SCREENS } from '../../constants/constants';
14import { ExperimentParameters } from '../../constants/interfaces';
15import { DesignProps } from './index';
16import SecondaryNavComponent from '../SecondaryNavComponent';
17import PreviewExperimentComponent from '../PreviewExperimentComponent';
18import { ParamSlider } from './ParamSlider';
19import PreviewButton from '../PreviewButtonComponent';
20import researchQuestionImage from '../../assets/common/ResearchQuestion2.png';
21import methodsImage from '../../assets/common/Methods2.png';
22import hypothesisImage from '../../assets/common/Hypothesis2.png';
23
24const CUSTOM_STEPS = {
25 OVERVIEW: 'OVERVIEW',
26 CONDITIONS: 'CONDITIONS',
27 TRIALS: 'TRIALS',
28 PARAMETERS: 'PARAMETERS',
29 INSTRUCTIONS: 'INSTRUCTIONS',
30 PREVIEW: 'PREVIEW',
31};
32
33const FIELDS = {
34 QUESTION: 'Research Question',
35 HYPOTHESIS: 'Hypothesis',
36 METHODS: 'Methods',
37 INTRO: 'Experiment Instructions',
38 HELP: 'Instructions for the task screen',
39};
40
41interface State {
42 activeStep: string;
43 isPreviewing: boolean;
44 params: ExperimentParameters;
45 saved: boolean;
46}
47
48export default class CustomDesign extends Component<DesignProps, State> {
49 constructor(props: DesignProps) {
50 super(props);
51 this.state = {
52 activeStep: CUSTOM_STEPS.OVERVIEW,
53 isPreviewing: true,
54 params: props.params,
55 saved: false,
56 };
57 this.handleStepClick = this.handleStepClick.bind(this);
58 this.handleStartExperiment = this.handleStartExperiment.bind(this);
59 this.handlePreview = this.handlePreview.bind(this);
60 this.handleSaveParams = this.handleSaveParams.bind(this);
61 this.handleProgressBar = this.handleProgressBar.bind(this);
62 this.handleEEGEnabled = this.handleEEGEnabled.bind(this);
63 this.endPreview = this.endPreview.bind(this);
64 }
65
66 endPreview() {
67 this.setState({ isPreviewing: false });
68 }
69
70 handleStepClick(step: string) {
71 this.handleSaveParams();
72 this.setState({ activeStep: step });
73 }
74
75 handleProgressBar(e: React.ChangeEvent<HTMLInputElement>) {
76 const { checked } = e.target;
77 this.setState((prevState) => ({
78 params: { ...prevState.params, showProgessBar: checked },
79 }));
80 }
81
82 handleEEGEnabled(e: React.ChangeEvent<HTMLInputElement>) {
83 this.props.ExperimentActions.SetEEGEnabled(e.target.checked);
84 }
85
86 handleStartExperiment() {
87 this.props.navigate(SCREENS.COLLECT.route);
88 }
89
90 handlePreview(e) {
91 e.target.blur();
92 this.setState({ isPreviewing: !this.state.isPreviewing });
93 }
94
95 handleSaveParams() {
96 this.props.ExperimentActions.SetParams(this.state.params);
97 this.props.ExperimentActions.SaveWorkspace();
98 this.setState({ saved: true });
99 }
100
101 handleSetText(text: string, section: 'hypothesis' | 'methods' | 'question') {
102 // @ts-expect-error
103 this.setState((prevState) => ({
104 params: {
105 ...prevState.params,
106 description: { ...prevState.params.description, [section]: text },
107 },
108 saved: false,
109 }));
110 }
111
112 renderSectionContent() {
113 const stimi = [
114 { name: 'stimulus1', number: 1 },
115 { name: 'stimulus2', number: 2 },
116 { name: 'stimulus3', number: 3 },
117 { name: 'stimulus4', number: 4 },
118 ];
119 switch (this.state.activeStep) {
120 case CUSTOM_STEPS.OVERVIEW:
121 default:
122 return (
123 <div className="flex gap-4 p-4 h-[90%]">
124 <div className="flex-1 flex flex-col items-center">
125 <img
126 src={researchQuestionImage}
127 className="h-[140px] w-auto"
128 alt="Research Question"
129 />
130 <label className="block text-sm font-medium mb-1">
131 {FIELDS.QUESTION}
132 </label>
133 <textarea
134 style={{ minHeight: 100, maxHeight: 400 }}
135 className="w-full border border-gray-300 rounded p-2"
136 value={this.state.params.description?.question}
137 placeholder="Explain your research question here."
138 onChange={(event) =>
139 this.handleSetText(event.target.value, 'question')
140 }
141 />
142 </div>
143 <div className="flex-1 flex flex-col items-center">
144 <img
145 src={hypothesisImage}
146 className="h-[140px] w-auto"
147 alt="Hypothesis"
148 />
149 <label className="block text-sm font-medium mb-1">
150 {FIELDS.HYPOTHESIS}
151 </label>
152 <textarea
153 style={{ minHeight: 100, maxHeight: 400 }}
154 className="w-full border border-gray-300 rounded p-2"
155 value={this.state.params.description?.hypothesis}
156 placeholder="Describe your hypothesis here."
157 onChange={(event) =>
158 this.handleSetText(event.target.value, 'hypothesis')
159 }
160 />
161 </div>
162 <div className="flex-1 flex flex-col items-center">
163 <img
164 src={methodsImage}
165 className="h-[140px] w-auto"
166 alt="Methods"
167 />
168 <label className="block text-sm font-medium mb-1">
169 {FIELDS.METHODS}
170 </label>
171 <textarea
172 style={{ minHeight: 100, maxHeight: 400 }}
173 className="w-full border border-gray-300 rounded p-2"
174 value={this.state.params.description?.methods}
175 placeholder="Explain how you will design your experiment to answer the question here."
176 onChange={(event) =>
177 this.handleSetText(event.target.value, 'methods')
178 }
179 />
180 </div>
181 </div>
182 );
183
184 case CUSTOM_STEPS.CONDITIONS:
185 return (
186 <div className="p-4">
187 <div className="mb-4">
188 <h1>Conditions</h1>
189 <p>
190 {`Select the folder with images for each condition and choose
191 the correct response. You can upload image files with the
192 following extensions: ".png", ".jpg", ".jpeg". Make sure when
193 you preview your experiment that the resolution is high enough.
194 You can resize or compress your images in an image editing
195 program or on one of the websites online.`}
196 </p>
197 </div>
198 <Table>
199 <TableHeader>
200 <TableRow>
201 <TableHead className="pl-[60px]">Condition</TableHead>
202 <TableHead>Default Key Response</TableHead>
203 <TableHead>Condition Folder</TableHead>
204 </TableRow>
205 </TableHeader>
206 <TableBody>
207 <TableRow>
208 <TableCell colSpan={3}>
209 Stimulus customization is currently unavailable
210 </TableCell>
211 </TableRow>
212 {stimi.map(({ name, number }) => (
213 <TableRow key={name}>
214 <TableCell
215 colSpan={3}
216 >{`Stimulus name: ${name}, number: ${number}`}</TableCell>
217 </TableRow>
218 ))}
219 </TableBody>
220 </Table>
221 </div>
222 );
223
224 case CUSTOM_STEPS.TRIALS:
225 return (
226 <div className="p-4">
227 <div className="grid grid-cols-[auto_1fr] w-full">
228 <div>
229 <h1>Trials</h1>
230 <p>Edit the correct key response and type of each trial.</p>
231 </div>
232 <div className="grid grid-cols-3 gap-2.5 self-end justify-self-end">
233 <div>
234 <label htmlFor="trial-order" className="block text-sm mb-1">Order</label>
235 <select
236 id="trial-order"
237 className="border border-gray-300 rounded px-2 py-1"
238 value={this.state.params.randomize}
239 onChange={(event) => {
240 const val = event.target.value;
241 if (val === 'sequential' || val === 'random') {
242 this.setState({
243 params: { ...this.state.params, randomize: val },
244 saved: false,
245 });
246 }
247 }}
248 >
249 <option value="random">Random</option>
250 <option value="sequential">Sequential</option>
251 </select>
252 </div>
253 <div>
254 <label htmlFor="nb-trials" className="block text-sm mb-1">
255 Total experimental trials
256 </label>
257 <input
258 id="nb-trials"
259 type="number"
260 className="border border-gray-300 rounded px-2 py-1"
261 value={this.state.params.nbTrials}
262 onChange={(event) =>
263 this.setState({
264 params: {
265 ...this.state.params,
266 nbTrials: parseInt(event.target.value, 10),
267 },
268 saved: false,
269 })
270 }
271 />
272 </div>
273 <div>
274 <label htmlFor="nb-practice-trials" className="block text-sm mb-1">
275 Total practice trials
276 </label>
277 <input
278 id="nb-practice-trials"
279 type="number"
280 className="border border-gray-300 rounded px-2 py-1"
281 value={this.state.params.nbPracticeTrials}
282 onChange={(event) =>
283 this.setState({
284 params: {
285 ...this.state.params,
286 nbPracticeTrials: parseInt(event.target.value, 10),
287 },
288 saved: false,
289 })
290 }
291 />
292 </div>
293 </div>
294 </div>
295 <Table>
296 <TableHeader>
297 <TableRow>
298 <TableHead className="pl-[60px]">Name</TableHead>
299 <TableHead>Condition</TableHead>
300 <TableHead>Correct Key Response</TableHead>
301 <TableHead>Trial Type</TableHead>
302 </TableRow>
303 </TableHeader>
304 <TableBody className="overflow-y-scroll max-h-[50vh] block">
305 <TableRow>
306 <TableCell colSpan={4}>
307 Stimulus customization is currently unavailable
308 </TableCell>
309 </TableRow>
310 </TableBody>
311 </Table>
312 </div>
313 );
314
315 case CUSTOM_STEPS.PARAMETERS:
316 return (
317 <div className="flex gap-4 p-4">
318 <div className="w-1/2 flex flex-col justify-between">
319 <div>
320 <h1>Inter-trial interval</h1>
321 <p>
322 Select the inter-trial interval duration. This is the amount
323 of time between trials measured from the end of one trial to
324 the start of the next one.
325 </p>
326 </div>
327 <div style={{ marginTop: '100px' }}>
328 <ParamSlider
329 label="ITI Duration (seconds)"
330 value={this.state.params.iti}
331 marks={{
332 1: '0.25',
333 2: '0.5',
334 3: '0.75',
335 4: '1',
336 5: '1.25',
337 6: '1.5',
338 7: '1.75',
339 8: '2',
340 }}
341 msConversion="250"
342 onChange={(value) =>
343 this.setState({
344 params: { ...this.state.params, iti: value },
345 saved: false,
346 })
347 }
348 />
349 </div>
350 </div>
351 <div className="w-1/2 flex flex-col justify-between">
352 <div>
353 <h1>Image duration</h1>
354 <p>
355 Select the time of presentation or make it self-paced -
356 present the image until participants respond.
357 </p>
358 </div>
359 <div>
360 <label className="flex items-center gap-2">
361 <input
362 type="checkbox"
363 defaultChecked={this.state.params.selfPaced}
364 onChange={() =>
365 this.setState({
366 params: {
367 ...this.state.params,
368 selfPaced: !this.state.params.selfPaced,
369 },
370 saved: false,
371 })
372 }
373 />
374 Self-paced data collection
375 </label>
376 </div>
377 {!this.state.params.selfPaced ? (
378 <div>
379 <ParamSlider
380 label="Presentation time (seconds)"
381 value={
382 this.state.params.presentationTime
383 ? this.state.params.presentationTime
384 : 0
385 }
386 marks={{
387 1: '0.25',
388 2: '0.5',
389 3: '0.75',
390 4: '1',
391 5: '1.25',
392 6: '1.5',
393 7: '1.75',
394 8: '2',
395 }}
396 msConversion="250"
397 onChange={(value) =>
398 this.setState({
399 params: {
400 ...this.state.params,
401 presentationTime: value,
402 },
403 saved: false,
404 })
405 }
406 />
407 </div>
408 ) : (
409 <div style={{ marginBottom: '85px' }} />
410 )}
411 </div>
412 </div>
413 );
414
415 case CUSTOM_STEPS.INSTRUCTIONS:
416 return (
417 <div className="flex gap-4 p-4">
418 <div className="w-1/2">
419 <h1>Experiment Instructions</h1>
420 <p>
421 Edit the instruction that will be displayed on the first screen.
422 </p>
423 <textarea
424 className="w-full border border-gray-300 rounded p-2"
425 style={{ minHeight: 150 }}
426 value={this.state.params.intro}
427 placeholder="e.g., You will view a series of faces and houses. Press 1 when a face appears and 9 for a house."
428 onChange={(event) => {
429 const val = event.target.value;
430 if (!isString(val)) return;
431 this.setState({
432 params: { ...this.state.params, intro: val },
433 saved: false,
434 });
435 }}
436 />
437 </div>
438 <div className="w-1/2">
439 <h1>Instructions for the task screen</h1>
440 <p>
441 Edit the instruction that will be displayed in the footer during
442 the task.
443 </p>
444 <textarea
445 className="w-full border border-gray-300 rounded p-2"
446 style={{ minHeight: 150 }}
447 value={this.state.params.taskHelp}
448 placeholder="e.g., Press 1 for a face and 9 for a house"
449 onChange={(event) => {
450 const val = event.target.value;
451 if (!isString(val)) return;
452 this.setState({
453 params: { ...this.state.params, taskHelp: val },
454 saved: false,
455 });
456 }}
457 />
458 </div>
459 </div>
460 );
461
462 case CUSTOM_STEPS.PREVIEW:
463 return (
464 <div className="flex items-start p-4 h-[90%]">
465 <div className="flex-1 h-full border border-brand rounded">
466 {this.props.type && (
467 <PreviewExperimentComponent
468 isPreviewing={this.state.isPreviewing}
469 onEnd={this.endPreview}
470 type={this.props.type}
471 experimentObject={this.props.experimentObject}
472 params={this.state.params}
473 title={this.props.title}
474 />
475 )}
476 </div>
477 <div className="flex-shrink-0 p-2">
478 <PreviewButton
479 isPreviewing={this.state.isPreviewing}
480 onClick={(e) => this.handlePreview(e)}
481 />
482 </div>
483 </div>
484 );
485 }
486 }
487
488 render() {
489 return (
490 <div className="h-screen p-[3%] bg-gradient-to-b from-[#f9f9f9] to-[#f0f0ff]">
491 <SecondaryNavComponent
492 title="Experiment Design"
493 steps={CUSTOM_STEPS}
494 activeStep={this.state.activeStep}
495 onStepClick={this.handleStepClick}
496 enableEEGToggle={
497 <input
498 type="checkbox"
499 defaultChecked={this.props.isEEGEnabled}
500 onChange={(event) => this.handleEEGEnabled(event)}
501 className="scale-75"
502 />
503 }
504 saveButton={
505 <Button
506 variant="secondary"
507 size="sm"
508 onClick={() => this.handleSaveParams()}
509 >
510 Save
511 </Button>
512 }
513 />
514 {this.renderSectionContent()}
515 </div>
516 );
517 }
518}