diff --git a/public/global.json b/public/global.json index f53cbe2dac..ef9949441d 100644 --- a/public/global.json +++ b/public/global.json @@ -40,6 +40,7 @@ "library-screen-recording", "library-smeq", "library-sus", + "library-virtual-chinrest", "library-vlat", "test-audio", "test-library", @@ -211,6 +212,10 @@ "path": "test-step-logic/config.json", "test": true }, + "library-virtual-chinrest": { + "path": "library-virtual-chinrest/config.json", + "test": true + }, "example-VLAT-adaptive": { "path": "example-VLAT-adaptive/config.json", "test": true diff --git a/public/libraries/virtual-chinrest/config.json b/public/libraries/virtual-chinrest/config.json new file mode 100644 index 0000000000..11eeb603ad --- /dev/null +++ b/public/libraries/virtual-chinrest/config.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.2.0/src/parser/LibraryConfigSchema.json", + "description": "A library for visual calibration tasks including virtual chinrest (card size) and viewing distance calibration using blindspot tracking.", + "reference": "Custom calibration library for visual perception studies", + "doi": "10.1038/s41598-019-57204-1", + "externalLink": "https://github.com/QishengLi/virtual_chinrest", + "components": { + "card-size": { + "type": "react-component", + "path": "libraries/virtual-chinrest/assets/VirtualChinrestCalibration.tsx", + "withSidebar": false, + "parameters": { + "taskid": "pixelsPerMM", + "itemWidthMM": 85.6, + "itemHeightMM": 53.98 + }, + "nextButtonLocation": "belowStimulus", + "response": [ + { + "id": "pixelsPerMM", + "prompt": "Calibration results", + "required": true, + "type": "reactive", + "hidden": true + } + ] + }, + "blindspot-distance": { + "type": "react-component", + "path": "libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx", + "nextButtonLocation": "belowStimulus", + "withSidebar": false, + "parameters": { + "blindspotAngle": 13.5 + }, + "response": [ + { + "id": "dist-calibration-MM", + "prompt": "Distance Calibration results in MM", + "required": true, + "type": "reactive", + "hidden": true + }, + { + "id": "dist-calibration-CM", + "prompt": "Distance Calibration results in CM", + "required": false, + "type": "reactive", + "hidden": true + }, + { + "id": "ball-positions", + "prompt": "Position of balls in pixels", + "required": false, + "type": "reactive", + "hidden": true + }, + { + "id": "square-position", + "prompt": "Position of the black square in pixels", + "required": false, + "type": "reactive", + "hidden": true + } + ] + } + }, + "sequences": { + "full": { + "id": "virtual-chinrest", + "order": "fixed", + "components": [ + "card-size", + "blindspot-distance" + ] + } + } + } \ No newline at end of file diff --git a/public/library-virtual-chinrest/assets/introduction.md b/public/library-virtual-chinrest/assets/introduction.md new file mode 100644 index 0000000000..b1fc9c2804 --- /dev/null +++ b/public/library-virtual-chinrest/assets/introduction.md @@ -0,0 +1,26 @@ +# Introduction + +This is an example of the library `virtual-chinrest`. + +The GitHub implementation is here: https://github.com/QishengLi/virtual_chinrest. + +The `jsPsych` plugin documentation is here: https://www.jspsych.org/v7/plugins/virtual-chinrest/. + +## References + +Li, Q., Joo, S. J., Yeatman, J. D., & Reinecke, K. (2020). Controlling for Participants’ Viewing Distance in Large-Scale, Psychophysical Online Experiments Using a Virtual Chinrest. Scientific Reports, 10(1), 1-11. doi: 10.1038/s41598-019-57204-1 + +``` +@article{Li2020, + doi = {10.1038/s41598-019-57204-1}, + url = {https://doi.org/10.1038/s41598-019-57204-1}, + year = {2020}, + month = jan, + publisher = {Springer Science and Business Media {LLC}}, + volume = {10}, + number = {1}, + author = {Qisheng Li and Sung Jun Joo and Jason D. Yeatman and Katharina Reinecke}, + title = {Controlling for Participants' Viewing Distance in Large-Scale, Psychophysical Online Experiments Using a Virtual Chinrest}, + journal = {Scientific Reports} +} +``` \ No newline at end of file diff --git a/public/library-virtual-chinrest/config.json b/public/library-virtual-chinrest/config.json new file mode 100644 index 0000000000..fff2f08313 --- /dev/null +++ b/public/library-virtual-chinrest/config.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.2.0/src/parser/StudyConfigSchema.json", + "studyMetadata": { + "title": "Virtual Chinrest Calibration", + "version": "1.0.0", + "authors": [ + "Qisheng Li", + "Sheng Long" + ], + "date": "2025-01-20", + "description": "Example study using the virtual chinrest calibration library.", + "organizations": [ + "" + ] + }, + "uiConfig": { + "contactEmail": "", + "logoPath": "revisitAssets/revisitLogoSquare.svg", + "withProgressBar": true, + "withSidebar": true + }, + "importedLibraries": [ + "virtual-chinrest" + ], + "components": { + "introduction": { + "type": "markdown", + "path": "library-virtual-chinrest/assets/introduction.md", + "response": [], + "nextButtonLocation": "belowStimulus" + } + }, + "sequence": { + "order": "fixed", + "components": [ + "introduction", + "$virtual-chinrest.se.full" + ] + } + } \ No newline at end of file diff --git a/src/components/NextButton.tsx b/src/components/NextButton.tsx index a56fc0a6c3..2cf8219f3f 100644 --- a/src/components/NextButton.tsx +++ b/src/components/NextButton.tsx @@ -12,7 +12,7 @@ import { PreviousButton } from './PreviousButton'; type Props = { label?: string; disabled?: boolean; - configInUse?: IndividualComponent; + config?: IndividualComponent; location?: ResponseBlockLocation; checkAnswer: JSX.Element | null; }; @@ -20,7 +20,7 @@ type Props = { export function NextButton({ label = 'Next', disabled = false, - configInUse, + config, location, checkAnswer, }: Props) { @@ -28,8 +28,8 @@ export function NextButton({ const studyConfig = useStudyConfig(); const navigate = useNavigate(); - const nextButtonDisableTime = useMemo(() => configInUse?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime, [configInUse, studyConfig]); - const nextButtonEnableTime = useMemo(() => configInUse?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0, [configInUse, studyConfig]); + const nextButtonDisableTime = useMemo(() => config?.nextButtonDisableTime ?? studyConfig.uiConfig.nextButtonDisableTime, [config, studyConfig]); + const nextButtonEnableTime = useMemo(() => config?.nextButtonEnableTime ?? studyConfig.uiConfig.nextButtonEnableTime ?? 0, [config, studyConfig]); const [timer, setTimer] = useState(undefined); // Start a timer on first render, update timer every 100ms @@ -59,7 +59,7 @@ export function NextButton({ [nextButtonDisableTime, nextButtonEnableTime, timer], ); - const nextOnEnter = useMemo(() => configInUse?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter, [configInUse, studyConfig]); + const nextOnEnter = useMemo(() => config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter, [config, studyConfig]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -78,12 +78,12 @@ export function NextButton({ }, [disabled, isNextDisabled, buttonTimerSatisfied, goToNextStep, nextOnEnter]); const nextButtonDisabled = useMemo(() => disabled || isNextDisabled || !buttonTimerSatisfied, [disabled, isNextDisabled, buttonTimerSatisfied]); - const previousButtonText = useMemo(() => configInUse?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous', [configInUse, studyConfig]); + const previousButtonText = useMemo(() => config?.previousButtonText ?? studyConfig.uiConfig.previousButtonText ?? 'Previous', [config, studyConfig]); return ( <> - {configInUse?.previousButton && ( + {config?.previousButton && ( currentConfig?.instructionLocation ?? studyConfig.uiConfig.instructionLocation ?? 'sidebar', [currentConfig, studyConfig]); const instructionInSideBar = instructionLocation === 'sidebar'; - return trialHasSideBar && currentConfig ? ( + return currentConfig ? ( {instructionInSideBar && instruction !== '' && ( )} - {trialHasSideBarResponses && ( - - - - )} + + + - ) : ( - - - - ); + ) : null; } diff --git a/src/components/response/ButtonsInput.tsx b/src/components/response/ButtonsInput.tsx index c2a453fb90..9c857109bd 100644 --- a/src/components/response/ButtonsInput.tsx +++ b/src/components/response/ButtonsInput.tsx @@ -44,8 +44,8 @@ export function ButtonsInput({ description={secondaryText} key={response.id} {...answer} - // This overrides the answers error. Which..is bad? error={error} + errorProps={{ c: required ? 'red' : 'orange' }} style={{ '--input-description-size': 'calc(var(--mantine-font-size-md) - calc(0.125rem * var(--mantine-scale)))' }} > diff --git a/src/components/response/CheckBoxInput.tsx b/src/components/response/CheckBoxInput.tsx index bb5a5e2f44..647a11b255 100644 --- a/src/components/response/CheckBoxInput.tsx +++ b/src/components/response/CheckBoxInput.tsx @@ -50,6 +50,7 @@ export function CheckBoxInput({ description={secondaryText} {...answer} error={error} + errorProps={{ c: required ? 'red' : 'orange' }} style={{ '--input-description-size': 'calc(var(--mantine-font-size-md) - calc(0.125rem * var(--mantine-scale)))' }} > diff --git a/src/components/response/DropdownInput.tsx b/src/components/response/DropdownInput.tsx index 518c4beca6..a152adb187 100644 --- a/src/components/response/DropdownInput.tsx +++ b/src/components/response/DropdownInput.tsx @@ -42,6 +42,8 @@ export function DropdownInput({ {...answer} value={answer.value === '' ? [] : Array.isArray(answer.value) ? answer.value : [answer.value]} error={generateErrorMessage(response, answer, optionsAsStringOptions)} + withErrorStyles={required} + errorProps={{ c: required ? 'red' : 'orange' }} classNames={{ input: classes.fixDisabled }} maxDropdownHeight={200} clearable @@ -59,6 +61,8 @@ export function DropdownInput({ {...answer} value={answer.value === '' ? null : answer.value} error={generateErrorMessage(response, answer, optionsAsStringOptions)} + withErrorStyles={required} + errorProps={{ c: required ? 'red' : 'orange' }} classNames={{ input: classes.fixDisabled }} maxDropdownHeight={200} /> diff --git a/src/components/response/MatrixInput.tsx b/src/components/response/MatrixInput.tsx index 2828d54232..12a5aa8bd9 100644 --- a/src/components/response/MatrixInput.tsx +++ b/src/components/response/MatrixInput.tsx @@ -10,6 +10,7 @@ import checkboxClasses from './css/Checkbox.module.css'; import radioClasses from './css/Radio.module.css'; import { useStoredAnswer } from '../../store/hooks/useStoredAnswer'; import { InputLabel } from './InputLabel'; +import { generateErrorMessage } from './utils'; function CheckboxComponent({ _choices, @@ -161,6 +162,8 @@ export function MatrixInput({ storeDispatch(setMatrixAnswersCheckbox(payload)); }; + const error = generateErrorMessage(response, answer); + const _n = _choices.length; const _m = orderedQuestions.length; return ( @@ -287,6 +290,11 @@ export function MatrixInput({ ))} + {error && ( + + {error} + + )} ); } diff --git a/src/components/response/NumericInput.tsx b/src/components/response/NumericInput.tsx index 8209d25dc1..49d2bfe064 100644 --- a/src/components/response/NumericInput.tsx +++ b/src/components/response/NumericInput.tsx @@ -20,8 +20,6 @@ export function NumericInput({ const { prompt, required, - min, - max, placeholder, secondaryText, infoText, @@ -35,10 +33,10 @@ export function NumericInput({ description={secondaryText} radius="md" size="md" - min={min} - max={max} {...answer} error={generateErrorMessage(response, answer)} + withErrorStyles={required} + errorProps={{ c: required ? 'red' : 'orange' }} classNames={{ input: classes.fixDisabled }} /> ); diff --git a/src/components/response/RadioInput.tsx b/src/components/response/RadioInput.tsx index 3450e27d54..5d149616dd 100644 --- a/src/components/response/RadioInput.tsx +++ b/src/components/response/RadioInput.tsx @@ -56,8 +56,8 @@ export function RadioInput({ description={secondaryText} key={response.id} {...answer} - // This overrides the answers error. Which..is bad? error={error} + errorProps={{ c: required ? 'red' : 'orange' }} style={{ '--input-description-size': 'calc(var(--mantine-font-size-md) - calc(0.125rem * var(--mantine-scale)))' }} > diff --git a/src/components/response/RankingInput.tsx b/src/components/response/RankingInput.tsx index 6d4fcc973c..6318887b56 100644 --- a/src/components/response/RankingInput.tsx +++ b/src/components/response/RankingInput.tsx @@ -570,7 +570,7 @@ export function RankingInput({ )} {secondaryText && {secondaryText}} {error && ( - + {error} )} diff --git a/src/components/response/ResponseBlock.tsx b/src/components/response/ResponseBlock.tsx index 96226a2d02..a7986dcd96 100644 --- a/src/components/response/ResponseBlock.tsx +++ b/src/components/response/ResponseBlock.tsx @@ -29,7 +29,7 @@ import { responseAnswerIsCorrect } from '../../utils/correctAnswer'; type Props = { status?: StoredAnswer; - config: IndividualComponent | null; + config: IndividualComponent; location: ResponseBlockLocation; style?: React.CSSProperties; }; @@ -65,14 +65,12 @@ export function ResponseBlock({ const navigate = useNavigate(); - const configInUse = config as IndividualComponent; - const allResponses = useMemo(() => (formOrders?.response ? formOrders.response - .map((id) => configInUse?.response?.find((r) => r.id === id)) + .map((id) => config?.response?.find((r) => r.id === id)) .filter((r): r is Response => r !== undefined) : [] - ), [configInUse?.response, formOrders]); + ), [config?.response, formOrders]); const responses = useMemo(() => allResponses.filter((r) => (r.location ? r.location === location : location === 'belowStimulus')), [allResponses, location]); @@ -129,16 +127,16 @@ export function ResponseBlock({ const studyConfig = useStudyConfig(); - const provideFeedback = useMemo(() => configInUse?.provideFeedback ?? studyConfig.uiConfig.provideFeedback, [configInUse, studyConfig]); - const hasCorrectAnswerFeedback = provideFeedback && ((configInUse?.correctAnswer?.length || 0) > 0); - const allowFailedTraining = useMemo(() => configInUse?.allowFailedTraining ?? studyConfig.uiConfig.allowFailedTraining ?? true, [configInUse, studyConfig]); + const provideFeedback = useMemo(() => config?.provideFeedback ?? studyConfig.uiConfig.provideFeedback, [config, studyConfig]); + const hasCorrectAnswerFeedback = provideFeedback && ((config?.correctAnswer?.length || 0) > 0); + const allowFailedTraining = useMemo(() => config?.allowFailedTraining ?? studyConfig.uiConfig.allowFailedTraining ?? true, [config, studyConfig]); const [attemptsUsed, setAttemptsUsed] = useState(0); - const trainingAttempts = useMemo(() => configInUse?.trainingAttempts ?? studyConfig.uiConfig.trainingAttempts ?? 2, [configInUse, studyConfig]); + const trainingAttempts = useMemo(() => config?.trainingAttempts ?? studyConfig.uiConfig.trainingAttempts ?? 2, [config, studyConfig]); const [enableNextButton, setEnableNextButton] = useState(false); const [hasCorrectAnswer, setHasCorrectAnswer] = useState(false); const usedAllAttempts = attemptsUsed >= trainingAttempts && trainingAttempts >= 0; const disabledAttempts = usedAllAttempts || hasCorrectAnswer; - const showBtnsInLocation = useMemo(() => location === (configInUse?.nextButtonLocation ?? studyConfig.uiConfig.nextButtonLocation ?? 'belowStimulus'), [configInUse, studyConfig, location]); + const showBtnsInLocation = useMemo(() => location === (config?.nextButtonLocation ?? studyConfig.uiConfig.nextButtonLocation ?? 'belowStimulus'), [config, studyConfig, location]); const identifier = useCurrentIdentifier(); const answerValidator = useAnswerField(responsesWithDefaults, currentStep, storedAnswer || {}); @@ -239,7 +237,7 @@ export function ResponseBlock({ }, {}) : {}) as StoredAnswer['answer']; const correctAnswers = Object.fromEntries(allResponsesWithDefaults.map((response) => { - const configCorrectAnswer = configInUse?.correctAnswer?.find((answer) => answer.id === response.id)?.answer; + const configCorrectAnswer = config?.correctAnswer?.find((answer) => answer.id === response.id)?.answer; const suppliedAnswer = allAnswers[response.id]; return [response.id, responseAnswerIsCorrect(suppliedAnswer, configCorrectAnswer)]; @@ -278,7 +276,7 @@ export function ResponseBlock({ message = `Please try again. You have ${trainingAttempts - newAttemptsUsed} attempts left.`; } if (response.type === 'checkbox') { - const correct = configInUse?.correctAnswer?.find((answer) => answer.id === response.id)?.answer; + const correct = config?.correctAnswer?.find((answer) => answer.id === response.id)?.answer; const suppliedAnswer = allAnswers[response.id] as string[]; const matches = findMatchingStrings(suppliedAnswer, correct); @@ -301,9 +299,9 @@ export function ResponseBlock({ ), ); } - }, [attemptsUsed, allResponsesWithDefaults, configInUse, hasCorrectAnswerFeedback, trainingAttempts, allowFailedTraining, storageEngine, navigate, identifier, storeDispatch, alertConfig, saveIncorrectAnswer, trialValidation]); + }, [attemptsUsed, allResponsesWithDefaults, config, hasCorrectAnswerFeedback, trainingAttempts, allowFailedTraining, storageEngine, navigate, identifier, storeDispatch, alertConfig, saveIncorrectAnswer, trialValidation]); - const nextOnEnter = configInUse?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter; + const nextOnEnter = config?.nextOnEnter ?? studyConfig.uiConfig.nextOnEnter; useEffect(() => { if (nextOnEnter) { @@ -321,14 +319,14 @@ export function ResponseBlock({ return () => {}; }, [checkAnswerProvideFeedback, nextOnEnter]); - const nextButtonText = useMemo(() => configInUse?.nextButtonText ?? studyConfig.uiConfig.nextButtonText ?? 'Next', [configInUse, studyConfig]); + const nextButtonText = useMemo(() => config?.nextButtonText ?? studyConfig.uiConfig.nextButtonText ?? 'Next', [config, studyConfig]); let index = 0; return ( <> {allResponsesWithDefaults.map((response) => { - const configCorrectAnswer = configInUse.correctAnswer?.find((answer) => answer.id === response.id)?.answer; + const configCorrectAnswer = config.correctAnswer?.find((answer) => answer.id === response.id)?.answer; const correctAnswer = Array.isArray(configCorrectAnswer) && configCorrectAnswer.length > 0 ? JSON.stringify(configCorrectAnswer) : configCorrectAnswer; // Check if this response is in the current location const isInCurrentLocation = responses.some((r) => r.id === response.id); @@ -362,7 +360,7 @@ export function ResponseBlock({ }} response={response} index={index} - configInUse={configInUse} + config={config} disabled={disabledAttempts} /> configInUse?.enumerateQuestions ?? studyConfig.uiConfig.enumerateQuestions ?? false, [configInUse, studyConfig]); + const enumerateQuestions = useMemo(() => config?.enumerateQuestions ?? studyConfig.uiConfig.enumerateQuestions ?? false, [config, studyConfig]); useFetchStylesheet(response.stylesheetPath); @@ -127,7 +127,7 @@ export function ResponseSwitcher({ }, [response.paramCapture, (response as MatrixResponse).questionOptions, (response as SliderResponse).startingValue, response.type, searchParams]); const responseStyle = response.style || {}; - const responseDividers = useMemo(() => response.withDivider ?? configInUse?.responseDividers ?? studyConfig.uiConfig.responseDividers, [response, configInUse, studyConfig]); + const responseDividers = useMemo(() => response.withDivider ?? config?.responseDividers ?? studyConfig.uiConfig.responseDividers, [response, config, studyConfig]); return ( diff --git a/src/components/response/SliderInput.tsx b/src/components/response/SliderInput.tsx index 3682b3a8bf..1d816cb34a 100644 --- a/src/components/response/SliderInput.tsx +++ b/src/components/response/SliderInput.tsx @@ -69,6 +69,7 @@ export function SliderInput({ description={secondaryText} error={errorMessage} style={{ '--input-description-size': 'calc(var(--mantine-font-size-md) - calc(0.125rem * var(--mantine-scale)))' }} + errorProps={{ c: required ? 'red' : 'orange' }} > {/* Vertical slider for SMEQ style */} {smeqStyle ? ( diff --git a/src/components/response/StringInput.tsx b/src/components/response/StringInput.tsx index 1295d488db..d7e9f56243 100644 --- a/src/components/response/StringInput.tsx +++ b/src/components/response/StringInput.tsx @@ -37,6 +37,8 @@ export function StringInput({ // This is necessary so the component doesnt switch from uncontrolled to controlled, which can cause issues. value={answer.value || ''} error={generateErrorMessage(response, answer)} + withErrorStyles={required} + errorProps={{ c: required ? 'red' : 'orange' }} classNames={{ input: classes.fixDisabled }} /> ); diff --git a/src/components/response/TextAreaInput.tsx b/src/components/response/TextAreaInput.tsx index a8b577b569..510728cec9 100644 --- a/src/components/response/TextAreaInput.tsx +++ b/src/components/response/TextAreaInput.tsx @@ -37,6 +37,8 @@ export function TextAreaInput({ // This is necessary so the component doesnt switch from uncontrolled to controlled, which can cause issues. value={answer.value || ''} error={generateErrorMessage(response, answer)} + withErrorStyles={required} + errorProps={{ c: required ? 'red' : 'orange' }} classNames={{ input: classes.fixDisabled }} /> ); diff --git a/src/components/response/utils.ts b/src/components/response/utils.ts index d5bc182775..652887cf38 100644 --- a/src/components/response/utils.ts +++ b/src/components/response/utils.ts @@ -1,7 +1,7 @@ import { useForm } from '@mantine/form'; import { useEffect, useState } from 'react'; import { - CheckboxResponse, DropdownResponse, NumberOption, RadioResponse, Response, StringOption, + CheckboxResponse, DropdownResponse, MatrixResponse, NumberOption, NumericalResponse, RadioResponse, Response, StringOption, } from '../../parser/types'; import { StoredAnswer } from '../../store/types'; @@ -19,27 +19,51 @@ function checkDropdownResponse(dropdownResponse: DropdownResponse, value: string return null; } -function checkCheckboxResponse(response: Response, value: string[]) { - if (response.type === 'checkbox') { - // Check max and min selections - const checkboxResponse = response as CheckboxResponse; - const minNotSelected = checkboxResponse.minSelections && value.length < checkboxResponse.minSelections; - const maxNotSelected = checkboxResponse.maxSelections && value.length > checkboxResponse.maxSelections; +function checkCheckboxResponse(response: CheckboxResponse, value: string[]) { + const minNotSelected = response.minSelections && value.length < response.minSelections; + const maxNotSelected = response.maxSelections && value.length > response.maxSelections; - if (minNotSelected && maxNotSelected) { - return `Please select between ${checkboxResponse.minSelections} and ${checkboxResponse.maxSelections} options`; - } - if (minNotSelected) { - return `Please select at least ${checkboxResponse.minSelections} options`; - } - if (maxNotSelected) { - return `Please select at most ${checkboxResponse.maxSelections} options`; - } + if (minNotSelected && maxNotSelected) { + return `Please select between ${response.minSelections} and ${response.maxSelections} options`; + } + if (minNotSelected) { + return `Please select at least ${response.minSelections} options`; + } + if (maxNotSelected) { + return `Please select at most ${response.maxSelections} options`; + } + return null; +} + +function checkNumericalResponse(response: NumericalResponse, value: number) { + const numValue = typeof value === 'string' ? parseFloat(value) : value; + + const { min, max } = response; + + if (min !== undefined && max !== undefined && (numValue < min || numValue > max)) { + return `Please enter a value between ${min} and ${max}`; + } + if (min !== undefined && numValue < min) { + return `Please enter a value of ${min} or greater`; } + if (max !== undefined && numValue > max) { + return `Please enter a value of ${max} or less`; + } + return null; +} + +function checkMatrixResponse(response: MatrixResponse, value: Record) { + const unanswered = Object.values(value).some((val) => val === ''); + + if (unanswered) { + return 'Please answer all questions in the matrix to continue.'; + } + return null; } const queryParameters = new URLSearchParams(window.location.search); + export const generateInitFields = (responses: Response[], storedAnswer: StoredAnswer['answer']) => { let initObj = {}; @@ -93,6 +117,9 @@ const generateValidation = (responses: Response[]) => { ...validateObj, [response.id]: (value: StoredAnswer['answer'][string], values: StoredAnswer['answer']) => { if (typeof value === 'object' && !Array.isArray(value) && value !== null) { + if (response.type === 'matrix-checkbox' || response.type === 'matrix-radio') { + return checkMatrixResponse(response, value); + } if (response.type === 'ranking-sublist' || response.type === 'ranking-categorical' || response.type === 'ranking-pairwise') { return Object.keys(value).length > 0 ? null : 'Empty Input'; } @@ -119,12 +146,20 @@ const generateValidation = (responses: Response[]) => { } return value.length === 0 ? 'Empty input' : null; } + if (response.required && response.requiredValue != null && value != null) { return value.toString() !== response.requiredValue.toString() ? 'Incorrect input' : null; } if (response.required) { - return (value === null || value === undefined || value === '') && !values[`${response.id}-dontKnow`] ? 'Empty input' : null; + if ((value === null || value === undefined || value === '') && !values[`${response.id}-dontKnow`]) { + return 'Empty input'; + } + if (response.type === 'numerical') { + return checkNumericalResponse(response, value as unknown as number); + // return 'Empty input'; + } } + return value === null ? 'Empty input' : null; }, }; @@ -153,18 +188,23 @@ export function useAnswerField(responses: Response[], currentStep: string | numb export function generateErrorMessage( response: Response, - answer: { value?: string | string[] | Record; checked?: string[] }, + answer: { value?: number | string | string[] | Record; checked?: string[] }, options?: (StringOption | NumberOption)[], ) { const { requiredValue, requiredLabel } = response; let error: string | null = ''; + if (answer.checked && Array.isArray(requiredValue)) { error = requiredValue && [...requiredValue].sort().toString() !== [...answer.checked].sort().toString() ? `Please ${options ? 'select' : 'enter'} ${requiredLabel || requiredValue.toString()} to continue.` : null; - } else if (answer.checked && response.required) { + } else if (answer.checked && response.required && response.type === 'checkbox') { error = checkCheckboxResponse(response, answer.checked); } else if (answer.value && response.type === 'dropdown') { error = checkDropdownResponse(response, answer.value as string[]); + } else if (answer.value && typeof answer.value === 'number' && response.type === 'numerical' && checkNumericalResponse(response, answer.value)) { + error = checkNumericalResponse(response, answer.value); + } else if (answer.value && typeof answer.value === 'object' && !Array.isArray(answer.value) && (response.type === 'matrix-radio' || response.type === 'matrix-checkbox')) { + return checkMatrixResponse(response, answer.value); } else { error = answer.value && requiredValue && requiredValue.toString() !== answer.value.toString() ? `Please ${options ? 'select' : 'enter'} ${requiredLabel || (options ? options.find((opt) => opt.value === requiredValue)?.label : requiredValue.toString())} to continue.` : null; } diff --git a/src/public/libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx b/src/public/libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx new file mode 100644 index 0000000000..a5dc622062 --- /dev/null +++ b/src/public/libraries/virtual-chinrest/assets/ViewingDistanceCalibration.tsx @@ -0,0 +1,248 @@ +import { + useState, useRef, useEffect, useCallback, +} from 'react'; +import { + Stack, List, Text, Container, +} from '@mantine/core'; +import { StimulusParams } from '../../../../store/types'; +import { useStoreSelector } from '../../../../store/store'; + +// Utility functions +const degToRadians = (degrees: number) => (degrees * Math.PI) / 180; + +export default function ViewingDistanceCalibration({ parameters, setAnswer }: StimulusParams<{ blindspotAngle: number }>) { + const ballRef = useRef(null); + const squareRef = useRef(null); + const animationFrameRef = useRef(null); + const { blindspotAngle } = parameters; + + const ans = useStoreSelector((state) => state.answers); + const cardSizeAnswer = Object.values(ans).find((answer) => answer.componentName === '$virtual-chinrest.components.card-size'); + const pixelsPerMM = Number(cardSizeAnswer?.answer?.pixelsPerMM); + + // States + const [ballPositions, setBallPositions] = useState([]); + const [isTracking, setIsTracking] = useState(false); + const [viewingDistance, setViewingDistance] = useState(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [clickCount, setClickCount] = useState(5); + + // Calculate viewing distance function + const calculateViewingDistance = useCallback((positions: number[]) => { + if (!positions.length || !pixelsPerMM || !squareRef.current) return; + + const avgBallPos = positions.reduce((a, b) => a + b, 0) / positions.length; + const squareRect = squareRef.current.getBoundingClientRect(); + const squarePos = squareRect.left; + const ballSquareDistance = Math.abs(avgBallPos - squarePos) / pixelsPerMM; + const viewDistance = ballSquareDistance / Math.tan(degToRadians(blindspotAngle)); + + setViewingDistance(viewDistance); + setIsTracking(false); + }, [blindspotAngle, pixelsPerMM]); + + // Reset ball to starting position + const resetBall = () => { + if (ballRef.current) { + ballRef.current.style.left = '740px'; + } + }; + + // Animation control functions + const startBlindspotTracking = useCallback(() => { + if (!ballRef.current || !squareRef.current || !pixelsPerMM || ballPositions.length >= 5) return; + setIsTracking(true); + + let isPaused = false; + let pauseStartTime = 0; + const PAUSE_DURATION = 1000; // 1 second pause + + const animateBall = (timestamp: number) => { + if (!ballRef.current) return; + + if (isPaused) { + if (timestamp - pauseStartTime >= PAUSE_DURATION) { + isPaused = false; + ballRef.current.style.left = '740px'; + } + animationFrameRef.current = requestAnimationFrame(animateBall); + return; + } + + const currentLeft = parseInt(ballRef.current.style.left || '740', 10); + const newLeft = currentLeft - 2; // Move left by decreasing left value + + // add looping effect + if (newLeft <= 0) { + isPaused = true; + pauseStartTime = timestamp; + // ballRef.current.style.left = '740px'; + } else { + ballRef.current.style.left = `${newLeft}px`; + } + + animationFrameRef.current = requestAnimationFrame(animateBall); + }; + + animationFrameRef.current = requestAnimationFrame(animateBall); + }, [ballPositions, pixelsPerMM]); + + const stopTracking = () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + setIsTracking(false); + }; + + useEffect(() => { + if (viewingDistance !== null && ballPositions.length === 5) { + setAnswer({ + status: true, + answers: { + 'dist-calibration-MM': viewingDistance, + 'dist-calibration-CM': viewingDistance / 10, + 'ball-positions': JSON.stringify(ballPositions), + 'square-position': squareRef.current?.getBoundingClientRect().left ?? 0, + }, + }); + } + }, [viewingDistance, ballPositions, setAnswer]); + + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + if (event.code === 'Space') { + event.preventDefault(); + + if (!isTracking) { + startBlindspotTracking(); + } else if (ballRef.current) { + stopTracking(); + const ballRect = ballRef.current.getBoundingClientRect(); + const newPosition = ballRect.left; + + setBallPositions((prev) => { + const newPositions = [...prev, newPosition]; + if (newPositions.length >= 5) { + calculateViewingDistance(newPositions); + } + return newPositions; + }); + + setClickCount((prev) => prev - 1); + resetBall(); + } + } + }; + + window.addEventListener('keydown', handleKeyPress); + return () => window.removeEventListener('keydown', handleKeyPress); + }, [isTracking, startBlindspotTracking, calculateViewingDistance]); + + // Reset state when pixelsPerMM changes + useEffect(() => { + setViewingDistance(null); + setIsTracking(false); + setBallPositions([]); + setClickCount(5); + resetBall(); + + return () => { + setViewingDistance(null); + setIsTracking(false); + setBallPositions([]); + setClickCount(5); + }; + }, []); + + // Cleanup animation frame on unmount + useEffect(() => () => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }, []); + + if (!pixelsPerMM) { + return
Please complete card calibration first.
; + } + return ( + + + Now we will quickly measure how far away you are sitting. + + + + Put your left hand on the + space bar + . + + Cover your right eye with your right hand. + Using your left eye, focus on the black square. Keep your focus on the black square. + + The + red ball + {' '} + will disappear as it moves from right to left. + Press the space bar as soon as the ball disappears. + + + + {ballPositions.length >= 5 + ? 'All measurements completed!' + : 'Press the space bar when you are ready to begin.'} + + +
+
+
+
+ + Remaining measurements: + {' '} + {5 - ballPositions.length} + + + {viewingDistance && ( + + Viewing Distance Results + + Estimated Viewing Distance: + {(viewingDistance / 10).toFixed(1)} + {' '} + cm + + + Number of measurements: + {ballPositions.length} + + + )} + + + ); +} diff --git a/src/public/libraries/virtual-chinrest/assets/VirtualChinrestCalibration.tsx b/src/public/libraries/virtual-chinrest/assets/VirtualChinrestCalibration.tsx new file mode 100644 index 0000000000..6ab52621af --- /dev/null +++ b/src/public/libraries/virtual-chinrest/assets/VirtualChinrestCalibration.tsx @@ -0,0 +1,148 @@ +/* eslint-disable react/no-unescaped-entities */ +import { useState, useRef, useEffect } from 'react'; +import { + Slider, Button, Stack, Text, +} from '@mantine/core'; +import { StimulusParams } from '../../../../store/types'; +import cardImage from './costco_card.png'; + +interface VirtualChinrestCalibrationProps extends StimulusParams<{ taskid: string }> { + itemWidthMM?: number; + itemHeightMM?: number; + fixedCorner?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; +} + +export default function VirtualChinrestCalibration({ + parameters, + setAnswer, + itemWidthMM = 85.6, // Standard credit card width + itemHeightMM = 53.98, // Standard credit card height + fixedCorner = 'top-left', // Default to top-left fixed corner +}: VirtualChinrestCalibrationProps) { + // Set states + const [itemWidthPx, setItemWidthPx] = useState(300); + const [pixelsPerMM, setPixelsPerMM] = useState(null); + const [isCalibrationComplete, setIsCalibrationComplete] = useState(false); + const [sliderRange, setSliderRange] = useState({ min: 100, max: 500 }); + const { taskid } = parameters; + + // Set references + const containerRef = useRef(null); + const wrapperRef = useRef(null); + + // Aspect ratio of the item + const aspectRatio = itemHeightMM / itemWidthMM; + + useEffect(() => { + const updateSliderRange = () => { + const screenWidth = window.innerWidth; + setSliderRange({ + min: Math.round(screenWidth * 0.1), // 10% of screen width + max: Math.round(screenWidth * 0.8), // 80% of screen width + }); + }; + + updateSliderRange(); + window.addEventListener('resize', updateSliderRange); + return () => window.removeEventListener('resize', updateSliderRange); + }, []); + + // Calculate height based on width and aspect ratio + const calculateHeight = (width: number) => Math.round(width * aspectRatio); + + // Pixel to MM Conversion + const convertPixelsToMM = (widthPx: number) => widthPx / itemWidthMM; + + // Handle a change in the slider + const handleSliderChange = (value: number) => { + setItemWidthPx(value); + }; + + const handleCalibrationComplete = () => { + if (!containerRef.current) return; + + const pxPerMM = convertPixelsToMM(itemWidthPx); + setPixelsPerMM(pxPerMM); + + // Prepare answer for the study framework + setAnswer({ + status: true, + answers: { + [taskid]: pxPerMM, + }, + }); + + setIsCalibrationComplete(true); + }; + + // Get position styles based on which corner should be fixed + const getPositionStyles = () => ({ + position: 'relative' as const, + width: '100%', + // height: `${calculateHeight(sliderRange.max)}px`, // Use maximum possible height + display: 'flex', + justifyContent: fixedCorner.includes('right') ? 'flex-end' : 'flex-start', + alignItems: fixedCorner.includes('bottom') ? 'flex-end' : 'flex-start', + }); + + return ( + // Container component from Mantine that centers content and provides max-width + + Drag the slider until the image is the same size as a credit card held up to the screen. + You can use any card this is the same size as a credit card, like a membership card or driver's license. + If you do not have access to a real card, you can use a ruler to measure the image width to 3.37 inches or 85.6mm. + Once you are finished, click 'Confirm Size' and then 'Next'. + + + + {/* Wrapper div that maintains position */} +
+ {/* Card container that changes size */} +
+ Credit Card +
+
+ + {isCalibrationComplete && ( +
+ Calibration Complete - Pixels per MM: + {' '} + {pixelsPerMM?.toFixed(2)} +
+ )} +
+ ); +} diff --git a/src/public/libraries/virtual-chinrest/assets/costco_card.png b/src/public/libraries/virtual-chinrest/assets/costco_card.png new file mode 100644 index 0000000000..4ae047c7be Binary files /dev/null and b/src/public/libraries/virtual-chinrest/assets/costco_card.png differ diff --git a/src/utils/handleRandomSequences.tsx b/src/utils/handleRandomSequences.tsx index 96c6d49082..10b379cacd 100644 --- a/src/utils/handleRandomSequences.tsx +++ b/src/utils/handleRandomSequences.tsx @@ -1,5 +1,6 @@ // eslint-disable-next-line import/no-unresolved import latinSquare from '@quentinroy/latin-square'; +import isEqual from 'lodash.isequal'; import { ComponentBlock, DynamicBlock, StudyConfig } from '../parser/types'; import { Sequence } from '../store/types'; import { isDynamicBlock } from '../parser/utils'; @@ -57,7 +58,7 @@ function _componentBlockToSequence( for (let i = 0; i < computedComponents.length; i += 1) { const curr = computedComponents[i]; if (typeof curr !== 'string' && !Array.isArray(curr)) { - const index = order.components.indexOf(curr); + const index = order.components.findIndex((c) => isEqual(c, curr)); computedComponents[i] = _componentBlockToSequence(curr, latinSquareObject, `${path}-${index}`) as unknown as ComponentBlock; } } @@ -122,7 +123,7 @@ function componentBlockToSequence( return _componentBlockToSequence(orderCopy, latinSquareObject, 'root'); } -function _createRandomOrders(order: StudyConfig['sequence'], paths: string[], path: string, index = 0) { +function _createRandomOrders(order: StudyConfig['sequence'], paths: string[], path: string, index: number) { const newPath = path.length > 0 ? `${path}-${index}` : 'root'; if (order.order === 'latinSquare') { paths.push(newPath); @@ -162,7 +163,8 @@ function generateLatinSquare(config: StudyConfig, path: string) { }); const options = (locationInSequence as ComponentBlock).components.map((c: unknown, i: number) => (typeof c === 'string' ? c : `_componentBlock${i}`)); - const newSquare: string[][] = latinSquare(options.sort(() => 0.5 - Math.random()), true); + shuffle(options); + const newSquare: string[][] = latinSquare(options, true); return newSquare; }