An easy-to-use platform for EEG experimentation in the classroom
at main 518 lines 18 kB view raw
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}