-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathFractController.j
More file actions
664 lines (581 loc) · 29 KB
/
FractController.j
File metadata and controls
664 lines (581 loc) · 29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
/*
This file is part of FrACT10, a vision test battery.
© 2021 Michael Bach, bach@uni-freiburg.de, <https://michaelbach.de>
2015-07-15 started
*/
@import <Foundation/CPObject.j>
@import "AlternativesGenerator.j"
@import "Thresholder.j";
@import "Optotypes.j";
@import "TrialHistoryController.j"
@import "MDBDispersionEstimation.j"
@typedef StateType
kStateDrawBack = 0; kStateDrawFore = 1; kStateDrawFore2 = 2;
/**
FractController
*/
@implementation FractController: CPWindowController {
int iTrial, nTrials, nAlternatives;
StateType state;
BOOL isBonusTrial, responseWasCorrect, responseWasCorrectCumulative;
char oldResponseKeyChar, responseKeyChar;
float stimStrengthInThresholderUnits, stimStrengthInDeviceunits, viewWidth, viewHeight, viewWidthHalf, viewHeightHalf;
float strokeSizeInPix, spatialFreqCPD, contrastMichelsonPercent;
float xEccInPix, yEccInPix; //eccentricity
Thresholder thresholder;
AlternativesGenerator alternativesGenerator, alternativesGeneratorEccentRandomizeX, alternativesGeneratorEccentRandomizeY;
Optotypes optotypes;
CPString trialInfoString @accessors;
CPTimer timerDisplay, timerResponse, timerFixMark, timerAutoResponse, timerIsi, timerBalmOff;
CPString kRangeLimitDefault, kRangeLimitOk, kRangeLimitValueAtFloor, kRangeLimitValueAtCeiling, rangeLimitStatus, abortCharacter, ci95String;
id sound @accessors;
BOOL responseButtonsAdded, isSpecialBcmDone;
BOOL discardKeyEntries; //this allows flushing the event queue to discard early responses
CPColor colorForeUndithered, colorBackUndithered;
}
- (void) updateViewWidthHeight {
viewWidth = CGRectGetWidth([[self window] frame]); viewWidthHalf = viewWidth / 2;
viewHeight = CGRectGetHeight([[self window] frame]); viewHeightHalf = viewHeight / 2;
}
- (id) initWithWindow: (CPWindow) aWindow { //console.info("FractController>initWithWindow");
self = [super initWithWindow: aWindow];
if (self) {
[[self window] setFullPlatformWindow: YES];
[aWindow setDelegate: self];
[self updateViewWidthHeight];
state = kStateDrawBack;
kRangeLimitDefault = ""; kRangeLimitOk = "rangeOK"; kRangeLimitValueAtFloor = "atFloor";
kRangeLimitValueAtCeiling = "atCeiling"; rangeLimitStatus = kRangeLimitDefault;
isSpecialBcmDone = NO;
optotypes = [[Optotypes alloc] init];
[Settings checkDefaults];
abortCharacter = kKEY_RESPONSE_ABORT;
[gAppController setRunAborted: YES];
[[self window] makeKeyAndOrderFront: self]; [[self window] makeFirstResponder: self];
//[self performSelector: @selector(runStart) withObject: nil afterDelay: 0.01]; //geht nicht mehr nach DEPLOY???
[MDBDispersionEstimation initResultStatistics]; ci95String = "";
//[self runStart];
//[self unittestContrastDeviceThresholdConversion];
}
return self;
}
- (void) runStart { //console.info("FractController>runStart");
[self _initializeTestParameters];
[self _initializeGenerators];
[self _initializeTestDetails];
[self trialStart];
}
- (void) _initializeTestParameters {
[self updateViewWidthHeight];
[[CPNotificationCenter defaultCenter] postNotificationName: "settingsDidChange" object: nil]; //make sure everything is updated
gStrokeMinimal = [Settings minStrokeAcuity]; //smallest possible stroke is ½pixel. Made into a Setting.
gStrokeMaximal = Math.min(viewHeight, viewWidth) / (5 + [Settings margin4maxOptotypeIndex]); //leave a margin of ½·index around the largest optotype
if (!([Settings showAcuityFormatLogMAR] || [Settings showAcuityFormatDecimal] || [Settings showAcuityFormatSnellenFractionFoot])) {
[Settings setShowAcuityFormatLogMAR: YES]; [Settings setShowAcuityFormatDecimal: YES]; //make sure not all formats are de-selected
}
responseButtonsAdded = NO;
iTrial = 0;
oldResponseKeyChar = " ";
state = kStateDrawBack;
responseWasCorrect = YES; responseWasCorrectCumulative = YES;
strokeSizeInPix = [MiscSpace pixelFromDegree: [Settings contrastOptotypeDiameter] / 60] / 5;
}
- (void) _initializeGenerators {
const obliqueOnlyG = [self isGratingAny] && [Settings isGratingObliqueOnly];
alternativesGenerator = [[AlternativesGenerator alloc] initWithNumAlternatives: nAlternatives andNTrials: nTrials obliqueOnly: obliqueOnlyG];
if ([Settings eccentRandomizeX]) {
alternativesGeneratorEccentRandomizeX = [[AlternativesGenerator alloc] initWithNumAlternatives: 2 andNTrials: nTrials obliqueOnly: NO];
}
if ([Settings eccentRandomizeY]) {
alternativesGeneratorEccentRandomizeY = [[AlternativesGenerator alloc] initWithNumAlternatives: 2 andNTrials: nTrials obliqueOnly: NO];
}
thresholder = [[Thresholder alloc] initWithNumAlternatives: nAlternatives];
[TrialHistoryController initWithNumTrials: nTrials];
}
- (void) _initializeTestDetails {
const dateTime = [CPDate date];
gTestDetails[td_dateTimeOfRunStart] = dateTime;
gTestDetails[td_dateOfRunStart] = [Misc date2YYYY_MM_DD: dateTime];
gTestDetails[td_timeOfRunStart] = [Misc date2HH_MM_SS: dateTime];
gTestDetails[td_decimalMark] = [Settings decimalMarkChar];
gTestDetails[td_ID] = [Settings patID];
gTestDetails[td_eyeCondition] = gEyeIndex2string[[Settings eyeIndex]];
gTestDetails[td_testName] = [Misc testNameGivenTestID: gCurrentTestID];
gTestDetails[td_eccentricityX] = [Settings eccentXInDeg];
gTestDetails[td_eccentricityY] = [Settings eccentYInDeg];
}
/**
This is a hook, for instance for the initial 4 acuity steps
*/
- (void) modifyDeviceStimulus { //console.info("FractController>modifyDeviceStimulus");
}
- (void) trialStart { //console.info("FractController>trialStart");
discardKeyEntries = YES;
iTrial += 1;
stimStrengthInThresholderUnits = [thresholder nextStim2apply]; //console.info("stimStrengthInThresholderUnits ", stimStrengthInThresholderUnits);
[self modifyThresholderStimulus]; //e.g. for bonus trials
stimStrengthInDeviceunits = [self stimDeviceunitsFromThresholderunits: stimStrengthInThresholderUnits]; //console.info("stimStrengthInDeviceunits ", stimStrengthInDeviceunits);
if (iTrial > nTrials) { //testing after new stimStrength so we can use final threshold
[self runEnd]; return;
}
[self modifyDeviceStimulus]; //e.g. let the first 4 follow DIN
if (isSpecialBcmDone) return;
[alternativesGenerator nextAlternative];
xEccInPix = -[MiscSpace pixelFromDegree: [Settings eccentXInDeg]];
yEccInPix = [MiscSpace pixelFromDegree: [Settings eccentYInDeg]]; //pos y: ↑
if ([Settings eccentRandomizeX]) {
if ([alternativesGeneratorEccentRandomizeX nextAlternative] !== 0) {
xEccInPix *= -1;
}
}
if ([Settings eccentRandomizeY]) {
if ([alternativesGeneratorEccentRandomizeY nextAlternative] !== 0) {
yEccInPix *= -1;
}
}
const tIsi = gBalmTestIDs.includes(gCurrentTestID) ? [Settings balmIsiMillisecs] : [Settings timeoutIsiMillisecs];
timerIsi = [CPTimer scheduledTimerWithTimeInterval: tIsi / 1000 target:self selector:@selector(onTimeoutIsi:) userInfo:nil repeats:NO];
state = kStateDrawBack; [[[self window] contentView] setNeedsDisplay: YES];
}
- (void) onTimeoutIsi: (CPTimer) timer { //CPLog("onTimeoutIsi");
//now we can draw the stimulus
let tDisp = (gCurrentTestID === kTestBalmLight) ? ([Settings balmOnMillisecs] / 1000) : [Settings timeoutDisplaySeconds]; //BaLM has its own setting
if ([self isContrastAny] && [Settings contrastShowFixMark]) {
tDisp += [Settings contrastTimeoutFixmark] / 1000; //correcting for fixmarktime
}
timerDisplay = [CPTimer scheduledTimerWithTimeInterval: tDisp target:self selector:@selector(onTimeoutDisplay:) userInfo:nil repeats:NO];
timerResponse = [CPTimer scheduledTimerWithTimeInterval: [Settings timeoutResponseSeconds] target:self selector:@selector(onTimeoutResponse:) userInfo:nil repeats:NO];
if ([Settings autoRunIndex] !== kAutoRunIndexNone) {
if ([self isAcuityOptotype] || [self isContrastAny] || [self isAcuityGrating]) {
let autoTime = 0.4 + [Settings timeoutIsiMillisecs] / 1000
if ([self isContrastAny]) {
autoTime += [Settings contrastTimeoutFixmark] / 1000;
}
timerAutoResponse = [CPTimer scheduledTimerWithTimeInterval: autoTime target:self selector:@selector(onTimeoutAutoResponse:) userInfo:nil repeats:NO];
}
}
state = kStateDrawFore; [[[self window] contentView] setNeedsDisplay: YES];
}
/**
Standard things for all tests, includes the display transform
*/
- (void) prepareDrawing { //console.info("FractController>prepareDrawing");
cgc = [[CPGraphicsContext currentContext] graphicsPort];
CGContextSetFillColor(cgc, gColorBack);
if ([self isAcuityTAO])
CGContextSetFillColor(cgc, [CPColor whiteColor]); //contrast always 100% with TAO
if ([self isContrastOptotype] && [Settings isContrastDithering]) {
CGContextSetFillColor(cgc, colorBackUndithered); //else black background is briefly visible, due to dithering delay
CGContextFillRect(cgc, [[self window] frame]);
CGContextSetFillColor(cgc, gColorBack);
}
CGContextFillRect(cgc, [[self window] frame]);
CGContextSaveGState(cgc);
CGContextTranslateCTM(cgc, viewWidthHalf, viewHeightHalf); //origin to center
CGContextTranslateCTM(cgc, -xEccInPix, -yEccInPix); //eccentric if desired
switch ([Settings displayTransform]) { //mirroring etc.
case 1: CGContextScaleCTM(cgc, -1, 1); break;
case 2: CGContextScaleCTM(cgc, 1, -1); break;
case 3: CGContextScaleCTM(cgc, -1, -1); break;
}
CGContextSetFillColor(cgc, gColorFore);
}
/**
At this time we have to undo the transform, so that the buttons in TAO are ok
*/
- (void) prepareDrawingTransformUndo {
switch ([Settings displayTransform]) { //opposite sequence than above
case 1: CGContextScaleCTM(cgc, -1, 1); break;
case 2: CGContextScaleCTM(cgc, 1, -1); break;
case 3: CGContextScaleCTM(cgc, -1, -1); break;
}
CGContextTranslateCTM(cgc, xEccInPix, yEccInPix); CGContextTranslateCTM(cgc, -viewWidthHalf, -viewHeightHalf);
}
/**
Draw the trial info (top left) after everything else has been drawn
*/
- (void) drawStimulusInRect: (CGRect) dirtyRect { //console.info("FractController>drawStimulusInRect");
if (gCurrentTestID == kTestAcuityLineByLine) return;
if (![Settings showTrialInfo]) return;
CGContextSetTextPosition(cgc, 10, 10); //we assume here no transformed CGContext
//CGContextSetFillColor(cgc, colOptotypeFore); would be unreadable with low contrast
CGContextSetFillColor(cgc, [CPColor darkGrayColor]);
CGContextSelectFont(cgc, [Settings trialInfoFontSize] + "px sans-serif");
CGContextShowText(cgc, trialInfoString);
}
/**
Embed in noise
*/
- (void) embedInNoise {
if (![Settings embedInNoise]) return;
if (!([self isAcuityOptotype] || [self isContrastOptotype])) return;
let checksize = [self isContrastAny] ? strokeSizeInPix : stimStrengthInDeviceunits;
checksize = Math.ceil(checksize / 5);
const aCheck = CGRectMake(0, 0, checksize, checksize);
const nx = Math.min(Math.ceil(viewWidthHalf / checksize), 16 * 5);
const ny = Math.min(Math.ceil(viewHeightHalf / checksize), 16 * 5);
const _alpha = [Settings noiseContrast] / 100;
for (let ix = -nx; ix < nx; ix++) {
for (let iy = -ny; iy < ny; iy++) {
aCheck.origin.x = checksize * ix;
aCheck.origin.y = checksize * iy;
const lum = [MiscLight devicegrayFromLuminance: Math.random()];
const col = [CPColor colorWithWhite: lum alpha: _alpha];
CGContextSetFillColor(cgc, col);
CGContextFillRect(cgc, aCheck);
}
}
}
/**
Draw touch controls
*/
- (void) drawTouchControls {
if ((![Settings enableTouchControls]) || (responseButtonsAdded)) return;
let sze = 52, sze2 = sze / 2;
switch (gCurrentTestID) { //kTestAcuityTAO, kTestAcuityVernier: done in instance
case kTestAcuityLetters: case kTestContrastLetters:
[self _drawTouchControlsForLetters: sze sze2: sze2];
break;
case kTestAcuityLandolt: case kTestContrastLandolt: case kTestContrastG:
[self _drawTouchControlsForC: sze sze2: sze2];
break;
case kTestAcuityE: case kTestContrastE:
[self _drawTouchControlsForE: sze sze2: sze2];
}
[self buttonCenteredAtX: viewWidth - sze2 - 1 y: viewHeightHalf - sze2 - 1 size: sze title: "Ø"];
}
- (void) _drawTouchControlsForLetters: (float) sze sze2: (float) sze2 {
sze = viewWidth / ((nAlternatives+1) * 1.4 + 1);
for (let i = 0; i < nAlternatives; i++) {
[self buttonCenteredAtX: (i + 0.9) * 1.4 * sze y: viewHeightHalf - sze2 - 1
size: sze title: [@"CDHKNORSVZØ" characterAtIndex: i]];
}
}
- (void) _drawTouchControlsForC: (float) sze sze2: (float) sze2 {
const radius = 0.5 * Math.min(viewWidth, viewHeight) - sze2 - 1;
for (let i = 0; i < 8; i++) {
if ( ([Settings nAlternatives] > 4) || (![Misc isOdd: i])) {
let iConsiderObliqueOnly = i;
if ((([Settings nAlternatives] === 4) && [Settings isLandoltObliqueOnly])
|| ([self isGratingAny] && [Settings isGratingObliqueOnly])) iConsiderObliqueOnly++;
const ang = iConsiderObliqueOnly / 8 * 2 * Math.PI;
[self buttonCenteredAtX: viewWidthHalf + Math.cos(ang) * radius y: Math.sin(ang) * radius size: sze title: [@"632147899" characterAtIndex: iConsiderObliqueOnly]];
}
}
}
- (void) _drawTouchControlsForE: (float) sze sze2: (float) sze2 {
[self buttonCenteredAtX: viewWidth-sze2 y: 0 size: sze title: kKEY_RESPONSE_RIGHT];
[self buttonCenteredAtX: sze2 y: 0 size: sze title: kKEY_RESPONSE_LEFT];
[self buttonCenteredAtX: viewWidthHalf y: -viewHeightHalf + sze2 size: sze title: kKEY_RESPONSE_UP];
[self buttonCenteredAtX: viewWidthHalf y: viewHeightHalf - sze2 size: sze title: kKEY_RESPONSE_DOWN];
}
- (CPButton) buttonCenteredAtX: (float) x y: (float) y size: (float) size title: (CPString) title {
[self buttonCenteredAtX: x y: y size: size title: title keyEquivalent: title];
}
- (CPButton) buttonCenteredAtX: (float) x y: (float) y size: (float) size title: (CPString) title keyEquivalent: (CPString) keyEquivalent { //console.info("FractControllerAcuityE>buttonAtX…", x, y, size, title, keyEquivalent);
y = y + viewHeightHalf //contentView is not affected by CGContextTranslateCTM, so I'm shifting y here to 0 at center
const sze2 = size / 2;
const button = [[CPButton alloc] initWithFrame: CGRectMake(x - sze2, y - sze2, size, size)];
[button setTitle: title]; [button setKeyEquivalent: keyEquivalent];
[button setTarget: self]; [button setAction: @selector(responseButton_action:)];
[button setBezelStyle: CPRoundRectBezelStyle];
[[[self window] contentView] addSubview: button];
responseButtonsAdded = YES;
return button;
}
- (IBAction) responseButton_action: (id) sender { //console.info("FractController>responseButton_action");
responseKeyChar = [sender keyEquivalent]; //console.info("<",responseKeyChar,">");
if (responseKeyChar === "Ø") [self runEnd];
else [super processKeyDownEvent];
}
- (void) onTimeoutDisplay: (CPTimer) timer { //console.info("FractController>onTimeoutDisplay");
state = kStateDrawBack; [[[self window] contentView] setNeedsDisplay: YES];
}
- (void) onTimeoutResponse: (CPTimer) timer { //console.info("FractController>onTimeoutResponse");
responseWasCorrect = NO;
[TrialHistoryController setResponded: -1];
[TrialHistoryController setPresented: [alternativesGenerator currentAlternative]];
[self trialEnd];
}
- (void) onTimeoutAutoResponse: (CPTimer) timer { //console.info("FractController>onTimeoutAutoResponse");
const arIndex = [Settings autoRunIndex] - 1;
if ([self isAcuityOptotype]) {
const logMARcurrent = [MiscSpace logMARFromStrokePixels: stimStrengthInDeviceunits];
let logMARtarget = [-0.3, 0.0, 0.3][arIndex];
if ([Settings doThreshCorrection]) logMARtarget += Math.log10(kThresholdCorrectionFactor4Ascending);
responseWasCorrect = logMARcurrent > logMARtarget;
}
if ([self isContrastOptotype]) {
responseWasCorrect = stimStrengthInDeviceunits < [1.8, 1.4, 1.0][arIndex];
}
if ([self isContrastG]) {
//const contrastMichelsonPercentCurrent = [MiscLight contrastMichelsonPercentFromLogCSWeber: stimStrengthInDeviceunits]
//responseWasCorrect = contrastMichelsonPercentCurrent > [30.0, 3.0, 0.3][arIndex];
responseWasCorrect = contrastMichelsonPercent > [30.0, 3.0, 0.3][arIndex];
}
if ([self isAcuityGrating]) {
responseWasCorrect = spatialFreqCPD < [0.3, 1, 10][arIndex];
}
[TrialHistoryController setPresented: [alternativesGenerator currentAlternative]];
[TrialHistoryController setResponded: -1]; //doesn't make sense on autorun, but something needs to be entered
[self trialEnd];
}
- (void) processKeyDownEvent { //console.info("FractController>processKeyDownEvent");
if (discardKeyEntries) return; //flushing the event queue to discard early responses
const ca = [alternativesGenerator currentAlternative];
if (responseKeyChar === "Ø") [self runEnd]; //added for the remote response box
const r = [self responseNumberFromChar: responseKeyChar];
[TrialHistoryController setPresented: ca];
[TrialHistoryController setResponded: r];
responseWasCorrect = (r === ca);
[self trialEnd];
}
//for a two directions/alternatives test
//0 & 4=valid; -1=ignore; -2=invalid
- (int) responseNumber2FromChar: (CPString) keyChar { //console.info("responseNumber2FromChar>responseNumberFromChar: ", keyChar);
switch (keyChar) { //0=no light, 4=light
case CPLeftArrowFunctionKey: case CPDownArrowFunctionKey:
case kKEY_RESPONSE_DOWN: case kKEY_RESPONSE_LEFT: return 0;
case CPRightArrowFunctionKey: case CPUpArrowFunctionKey:
case kKEY_RESPONSE_RIGHT: case kKEY_RESPONSE_UP: return 4;
case kKEY_RESPONSE_ABORT: return -1;
}
return -2; //invalid key
}
//for a four cardinal directions/alternatives
- (int) responseNumber4FromChar: (CPString) keyChar {
//console.info("FractController>responseNumber4FromChar: ", keyChar);
switch (keyChar) {
case CPRightArrowFunctionKey: case kKEY_RESPONSE_RIGHT: return 0; //→
case CPDownArrowFunctionKey: case kKEY_RESPONSE_DOWN: return 6; //↓
case CPLeftArrowFunctionKey: case kKEY_RESPONSE_LEFT: return 4; //←
case CPUpArrowFunctionKey: case kKEY_RESPONSE_UP: return 2; //↑
case kKEY_RESPONSE_ABORT: return -1;
}
return -2; //invalid key
}
//8 directions/alternatives, this can be used for Landolt rings
//0–8: valid; -1: ignore; -2: invalid
- (int) responseNumber8FromChar: (CPString) keyChar { //console.info("FractController>responseNumber8FromChar: ", keyChar);
switch (keyChar) {
case CPLeftArrowFunctionKey: case kKEY_RESPONSE_LEFT: return 4;
case CPRightArrowFunctionKey: case kKEY_RESPONSE_RIGHT: return 0;
case CPUpArrowFunctionKey: case kKEY_RESPONSE_UP: return 2;
case CPDownArrowFunctionKey: case kKEY_RESPONSE_DOWN: return 6;
case kKEY_RESPONSE_UP_RIGHT: return 1;
case kKEY_RESPONSE_UP_LEFT: return 3;
case kKEY_RESPONSE_DOWN_LEFT: return 5;
case kKEY_RESPONSE_DOWN_RIGHT: return 7;
case kKEY_RESPONSE_ABORT: return -1;
}
return -2; //invalid key
}
//10 alternatives, this can be used for Letters
- (int) responseNumber10FromChar: (CPString) keyChar { //console.info("FractController>responseNumber10FromChar: ", keyChar);
const keyMap = {"C": 0, "D": 1, "H": 2, "K": 3, "N": 4, "O": 5,
"R": 6, "S": 7, "V": 8, "Z": 9, kKEY_RESPONSE_ABORT: -1};
const result = keyMap[[keyChar uppercaseString]];
return result !== undefined ? result : -2; //if key exists, return its value, otherwise -2
}
- (void) invalidateTrialTimers {
[timerDisplay invalidate]; timerDisplay = nil;
[timerResponse invalidate]; timerResponse = nil;
[timerAutoResponse invalidate]; timerAutoResponse = nil;
[timerIsi invalidate]; timerIsi = nil;
}
- (void) trialEnd { //console.info("FractController>trialEnd");
[self invalidateTrialTimers];
CGContextSetFillColor(cgc, gColorBack); //need to clear for ISI to work
CGContextFillRect(cgc, [[self window] frame]);
[TrialHistoryController setIsCorrect: responseWasCorrect]; //placed here so reached by "onTimeoutAutoResponse"
[thresholder enterTrialOutcomeWithAppliedStim: [self stimThresholderunitsFromDeviceunits: stimStrengthInDeviceunits] wasCorrect: responseWasCorrect];
switch ([Settings auditoryFeedback4trialIndex]) { //case 0: nothing
case kauditoryFeedback4trialIndexAlways:
[sound playNumber: kSoundTrialYes]; break;
case kauditoryFeedback4trialIndexOncorrect:
if (responseWasCorrect) [sound playNumber: kSoundTrialYes]; break;
case kauditoryFeedback4trialIndexWithinfo:
if (responseWasCorrect) [sound playNumber: kSoundTrialYes];
else [sound playNumber: kSoundTrialNo];
break;
}
[TrialHistoryController trialEnded];
[self trialStart];
}
//For crowing: gap between optotype border and border of the crowder
- (float) crowdingGapFromStrokeWidth: (float) stroke {
let returnVal = 2 * stroke; //case 0
switch ([Settings crowdingDistanceCalculationType]) {
case 1:
returnVal = [MiscSpace pixelFromDegree: 2.6 / 60.0]; break;
case 2:
returnVal = [MiscSpace pixelFromDegree: 30 / 60.0]; break;
case 3: //1 optotype (like ETDRS)
returnVal = 5 * stroke; break;
}
return returnVal;
}
- (async void) runEnd { //console.info("FractController>runEnd");
[self invalidateTrialTimers];
const sv = [[[self window] contentView] subviews];
for (const svi of sv) [svi removeFromSuperview];
[[self window] close];
[gAppController setRunAborted: (iTrial < nTrials)]; //premature end?
[gAppController setCurrentTestResultExportString: [self composeExportString]];
//delay to give the screen time to update for immediate response feedback
await [Misc asyncDelaySeconds: 0.03];
[TrialHistoryController runEnded];
[gAppController setCurrentTestResultsHistoryExportString: [TrialHistoryController resultsHistoryString]];
if ([Settings giveAuditoryFeedback4run]) [sound playNumber: kSoundRunEnd];
let exportString = [gAppController currentTestResultExportString];
exportString = [self _appendCI95InfoToString: exportString];
exportString = [self _appendColorInfoToString: exportString];
exportString = [self _appendNoiseInfoToString: exportString];
exportString = [self _appendGratingInfoToString: exportString];
[gAppController setCurrentTestResultExportString: exportString + crlf];
[gAppController runEnd];
}
- (CPString) _appendCI95InfoToString: (CPString) exportString {
if ([Settings showCI95] && (![gAppController runAborted])) {
if ([self isAcuityOptotype]) {
//the below causes a delay of < 1 s with nSamples=10,000
const historyResults = [TrialHistoryController composeInfo4CI];
gTestDetails[td_halfCI95] = [MDBDispersionEstimation calculateCI95halfFromDF: historyResults guessingProbability: 1.0 / nAlternatives nSamples: gNSamplesCI95];
ci95String = " ± " + [Misc stringFromNumber: gTestDetails[td_halfCI95] decimals: 2 localised: YES];
[gAppController setResultStringFieldTo: [self acuityComposeResultString]]; //this will add CI95 info
exportString += tab + "halfCI95" + tab + [Misc stringFromNumber: gTestDetails[td_halfCI95] decimals: 3 localised: YES];
}
}
return exportString;
}
- (CPString) _appendColorInfoToString: (CPString) exportString {
if ([Settings isAcuityColor]) {
if ([self isAcuityOptotype] && (![self isAcuityTAO])) {
exportString += tab + "colorFore" + tab + [gColorFore hexString];
exportString += tab + "colorBack" + tab + [gColorBack hexString];
gTestDetails[td_colorFore] = gColorFore; gTestDetails[td_colorBack] = gColorBack;
}
}
return exportString;
}
- (CPString) _appendNoiseInfoToString: (CPString) exportString {
if ([Settings embedInNoise]) {
if (([self isAcuityOptotype] || [self isContrastOptotype]) && (![self isAcuityTAO])) {
exportString += tab + "noiseContrast" + tab + [Misc stringFromInteger: [Settings noiseContrast]];
gTestDetails[td_noiseContrast] = [Settings noiseContrast];
}
}
return exportString;
}
- (CPString) _appendGratingInfoToString: (CPString) exportString {
if (gCurrentTestID === kTestContrastG) {
exportString += tab + "gratingShape" + tab + [Settings gratingShapeIndex];
gTestDetails[td_gratingShape] = [Settings gratingShapeIndex];
}
return exportString;
}
- (BOOL) acceptsFirstResponder { //console.info("FractController>acceptsFirstResponder");
return YES;
}
/**
Here's were we read the response keys
*/
- (void) keyDown: (CPEvent) theEvent { //console.info("FractController>keyDown");
responseKeyChar = [[[theEvent characters] characterAtIndex: 0] uppercaseString];
const responseKeyCode = [theEvent keyCode];
if ((responseKeyCode === CPEscapeKeyCode) || ((responseKeyChar === abortCharacter) && (oldResponseKeyChar === abortCharacter))) {
[self runEnd]; return;
}
oldResponseKeyChar = responseKeyChar;
if (responseKeyChar !== abortCharacter) [self processKeyDownEvent];
}
/**
"stimThresholderunits" are on a linear 0…1 scale
"Deviceunits" are the corresponding pixels for acuity or logCSWeber for contrast
*/
- (float) stimThresholderunitsFromDeviceunits: (float) ntve {
console.info("FractController>stimThresholderunitsFromDeviceunits OVERRIDE THIS!");
return ntve;
}
- (float) stimDeviceunitsFromThresholderunits: (float) generic {
console.info("FractController>stimDeviceunitsFromThresholderunits OVERRIDE THIS!");
return generic;
}
- (void) modifyThresholderStimulusWithBonus {
if (iTrial > nTrials) return; //don't change if done
isBonusTrial = (iTrial % 6 === 0) && (iTrial !== 6);
if (isBonusTrial) stimStrengthInThresholderUnits = Math.min(stimStrengthInThresholderUnits + 0.2, 1.0);
}
/**
Calculate ≤ or ≥ as needed. Needs to be inverted for LogMAR
*/
- (CPString) rangeStatusIndicatorStringInverted: (BOOL) invert {
//console.info("FractController>rangeStatusIndicatorStringInverted");
let sFloor = kRangeLimitValueAtFloor, sCeil = kRangeLimitValueAtCeiling, s = "";
if (invert) {
let sTemp = sCeil; sCeil = sFloor; sFloor = sTemp;
}
if (rangeLimitStatus === sFloor) {
s += " ≥ ";
} else {
if (rangeLimitStatus === sCeil) {
s += " ≤ ";
} else {
s += " ";
}
}
//console.info(s);
return s;
}
/**
Initial part of the export string, used by acuity & contrast…
*/
- (CPString) generalComposeExportString { //console.info("FractController>generalComposeExportString");
const nowDateTime = [CPDate date];
let s = "vsExportFormat" + tab + gVersionOfExportFormat;
s += tab + "vsFrACT" + tab + gVersionDateOfFrACT;
s += tab + "decimalMark" + tab + [Settings decimalMarkChar];
s += tab + "ID" + tab + [Settings patID];
s += tab + "eyeCondition" + tab + gEyeIndex2string[[Settings eyeIndex]];
s += tab + "date" + tab + [Misc date2YYYY_MM_DD: nowDateTime];
s += tab + "time" + tab + [Misc date2HH_MM_SS: nowDateTime];
s += tab + "test" + tab + [Misc testNameGivenTestID: gCurrentTestID];
return s;
}
//in order to not mangle parameter sequence I'm tucking this addition at the end
//to be used for optional conditions
- (CPString) generalComposeExportStringFinalize: (CPString) s {
if (([Settings eccentXInDeg] !== 0) || ([Settings eccentYInDeg] !== 0)) {
s += tab + "eccentricityX" + tab + [Misc stringFromNumber: [Settings eccentXInDeg] decimals: 1 localised: YES];
s += tab + "eccentricityY" + tab + [Misc stringFromNumber: [Settings eccentYInDeg] decimals: 1 localised: YES];
}
return s;
}
/**
Helpers
*/
- (BOOL) isAcuityTAO {
return [kTestAcuityTAO].includes(gCurrentTestID);
}
- (BOOL) isAcuityOptotype {
return [kTestAcuityLetters, kTestAcuityLandolt, kTestAcuityE, kTestAcuityTAO].includes(gCurrentTestID);
}
- (BOOL) isAcuityGrating {
return (gCurrentTestID === kTestContrastG) && ([Settings what2sweepIndex] === 1);
}
- (BOOL) isAcuityAny {
return ([self isAcuityOptotype] || (gCurrentTestID === kTestAcuityVernier) || [self isAcuityGrating]);
}
- (BOOL) isContrastG {
return [kTestContrastG].includes(gCurrentTestID) && (![self isAcuityGrating]);
}
- (BOOL) isContrastOptotype { //console.info("isContrastOptotype ", gCurrentTestID);
return [kTestContrastLetters, kTestContrastLandolt, kTestContrastE].includes(gCurrentTestID);
}
- (BOOL) isContrastAny {
return [self isContrastOptotype] || (gCurrentTestID === kTestContrastG);
}
- (BOOL) isGratingAny {
return gCurrentTestID === kTestContrastG;
}
@end