gazeplotter
Version:
Gazeplotter is a Svelte application for visualizing eye-tracking data.
283 lines (282 loc) • 12.9 kB
JavaScript
import { AbstractEyeDeserializer } from './AbstractEyeDeserializer.ts';
/**
* Deserializer for Tobii eyetracking files.
* @extends AbstractEyeDeserializer
* @category Eye
* @subcategory Deserializer
* @member cAoiInfo - Array of objects containing information about AOI columns. It's being iterated over to find active AOIs, so Array > Map here.
* @member cRecordingTimestamp - Index of the Recording timestamp column.
* @member cStimulus - Index of the Presented Stimulus name column.
* @member cParticipant - Index of the Participant name column.
* @member cRecording - Index of the Recording name column.
* @member cCategory - Index of the Eye movement type column.
* @member cEvent - Index of the Event column.
* @member cEyeMovementTypeIndex - Index of the Eye movement type index column.
* @member mStimulus - Current stimulus name.
* @member mParticipant - Current participant name.
* @member mRecordingStart - Current recording start timestamp.
* @member mEyeMovementTypeIndex - Current eye movement type index.
* @member mRecordingLast - Current recording last timestamp.
* @member mCategory - Current eye movement type.
* @member mAoi - Current AOIs.
* @member mBaseTime - Current base time.
* @member stimuliRevisit - Object containing information about revisited stimuli.
* @member stimulusGetter - Function that returns the stimulus name, either from the Presented Stimulus name column or from the Event column.
*/
export class TobiiEyeDeserializer extends AbstractEyeDeserializer {
cAoiInfo;
cRecordingTimestamp;
cStimulus;
cParticipant;
cRecording;
cCategory;
cEvent;
cEyeMovementTypeIndex;
mStimulus = '';
mParticipant = '';
mRecordingStart = '';
mEyeMovementTypeIndex = '';
mRecordingLast = '';
mCategory = '';
mAoi = null;
mBaseTime = '';
/* stimuliRevisit: Record<string, number> = {} // Using an object to track the stimulus_participant revisit count */
stimulusGetter;
stimuliBaseTimes = new Map();
static TYPE = 'tobii';
static TIME_MODIFIER = 0.001; // microseconds to milliseconds
/**
* @group Initialization
* @description Initializes the deserializer with headers and user input.
* @param {string[]} header - Array of header names.
* @param {string} userInput - User-defined input for interval markers.
*/
constructor(header, userInput) {
super();
this.cRecordingTimestamp = header.indexOf('Recording timestamp');
this.cStimulus = header.indexOf('Presented Stimulus name');
this.cParticipant = header.indexOf('Participant name');
this.cRecording = header.indexOf('Recording name');
this.cCategory = header.indexOf('Eye movement type');
this.cEvent = header.indexOf('Event');
this.cEyeMovementTypeIndex = header.indexOf('Eye movement type index');
this.cAoiInfo = this.constructAoiMapping(header, this.constructStimuliDictionary(header));
this.stimulusGetter =
userInput === ''
? this.constructBaseStimulusGetter()
: this.constructIntervalStimulusGetter(userInput);
}
/**
* @group Initialization
* @description Creates a dictionary of stimuli from the header. Specifically, it looks for columns starting with 'AOI hit [STIMULUS_NAME' and extracts the stimulus name.
* @param {string[]} header - Array of header names.
* @returns {string[]} Array of unique stimuli names based on AOI hit columns.
*/
constructStimuliDictionary(header) {
const aoiColumns = header.filter(x => x.startsWith('AOI hit ['));
return [
...new Set(aoiColumns.map(x => x.replace(/AOI hit \[|\s-.*?]/g, '')).sort()),
];
}
/**
* @group Initialization
* @description Creates an array of objects containing information about AOI columns.
* @param {string[]} header - Array of header names.
* @param {string[]} stimuliDictionary - Array of unique stimuli names based on AOI hit columns.
* @returns {Array<{ columnPosition: number; aoiName: string; stimulusName: string }>} Array of objects containing information about AOI columns.
* @example [{ columnPosition: 5, aoiName: 'AOI_1', stimulusName: 'Stimulus_1' }]
*/
constructAoiMapping(header, stimuliDictionary) {
return stimuliDictionary.flatMap(stimulus => {
return header
.filter(x => x.startsWith(`AOI hit [${stimulus}`))
.map(aoiItem => ({
columnPosition: header.indexOf(aoiItem),
aoiName: aoiItem.replace(/A.*?- |]/g, ''),
stimulusName: stimulus,
}));
});
}
/**
* @group StimulusGetterInitialization
* @description Creates a function that returns the stimulus name from the Presented Stimulus name column.
* @returns {(row: string[]) => string} Function that returns the stimulus name from the Presented Stimulus name column.
* @example (row: string[]) => row[5]
*/
constructBaseStimulusGetter() {
const stimulusGetterFunction = (row) => {
return row[this.cStimulus];
};
return stimulusGetterFunction;
}
/**
* @group StimulusGetterInitialization
* @description Creates a function that returns the stimulus name based on interval information in the Event column.
* @param {string} userInput - User-defined input for interval markers.
* @returns {(row: string[]) => string} Function that returns the stimulus name based on the Event column.
*/
constructIntervalStimulusGetter(userInput) {
const { startMarker, endMarker } = this.constructIntervalMarkers(userInput);
const stimulusGetterFunction = (row) => {
// there is now start of nes stimulus indicated in Event column by value in this format:
// "NAME_OF_STIMULUS IntervalStart"
const event = row[this.cEvent];
// if contains IntervalStart, then it is the start of a new stimulus
let stimulus = this.mStimulus;
if (event === '' || event === undefined)
return stimulus;
if (event.includes(startMarker)) {
stimulus = event.replace(startMarker, '');
}
if (event.includes(endMarker)) {
stimulus = '';
}
return stimulus;
};
return stimulusGetterFunction;
}
/**
* @group StimulusGetterInitialization
* @description Extracts interval markers from user input to be used in the Event column for stimulus name extraction.
* @param {string} userInput - User-defined input for interval markers.
* @returns {{ startMarker: string; endMarker: string }} Object containing start and end interval markers.
* @example { startMarker: ' IntervalStart', endMarker: ' IntervalEnd' }
* @throws {Error} Throws an error if the user input does not contain exactly two interval markers.
*/
constructIntervalMarkers(userInput) {
const markers = userInput.split(';');
if (markers.length !== 2) {
throw new Error(`Invalid interval markers. Expected format: "start;end". Got: "${userInput}"`);
}
return { startMarker: markers[0], endMarker: markers[1] };
}
/**
* @group Deserialization
* @description Deserializes a row of data.
* @param {string[]} row - Row of data.
* @returns {SingleDeserializerOutput | null} Deserialized data.
* @example { stimulus: 'Stimulus_1', participant: 'Participant_1', start: '0', end: '1000', category: 'Fixation', aoi: ['AOI_1'] }
*/
deserialize(row) {
if (this.isEmptyRow(row))
return null; // skip empty rows
if (this.isSameSegment(row))
return this.deserializeSameSegment(row);
return this.deserializeNewSegment(row);
}
/**
* @group Deserialization
* @description Deserializes a row of data that belongs to the same segment as the previous row. Always returns null. Saves the last timestamp of the segment to be used in case of a new segment in the next row.
* @param {string[]} row - Row of data.
* @returns {null} Always returns null.
*/
deserializeSameSegment(row) {
this.mRecordingLast = row[this.cRecordingTimestamp];
return null;
}
/**
* @group Deserialization
* @description Deserializes a row of data that belongs to a new segment. Releases the previous segment and starts a new one.
* @param {string[]} row - Row of data.
* @returns {SingleDeserializerOutput | null} Deserialized data.
* @example { stimulus: 'Stimulus_1', participant: 'Participant_1', start: '0', end: '1000', category: 'Fixation', aoi: ['AOI_1'] }
*/
deserializeNewSegment(row) {
const eyeMovementTypeIndex = row[this.cEyeMovementTypeIndex];
const recordingTimestamp = row[this.cRecordingTimestamp];
this.mEyeMovementTypeIndex = eyeMovementTypeIndex;
const previousSegment = this.getPreviousSegment();
const stimulus = this.stimulusGetter(row);
const participant = row[this.cRecording] + ' ' + row[this.cParticipant];
// const category = row[this.cCategory]
const aoi = this.getAoisFromRow(row);
// change base time if change of stimulus / participant
/*if (
stimulus !== this.mStimulus ||
participant !== this.mParticipant ||
this.mBaseTime === ''
) {
this.mBaseTime = recordingTimestamp
const key = this.mStimulus + this.mParticipant
if (this.mStimulus !== '') {
this.stimuliRevisit[key] =
this.stimuliRevisit[key] !== undefined
? this.stimuliRevisit[key] + 1
: 0
}
this.mStimulus = stimulus
}*/
if (this.stimuliBaseTimes.get(stimulus + participant) === undefined) {
this.stimuliBaseTimes.set(stimulus + participant, recordingTimestamp);
}
// save newly began segment
this.mParticipant = participant;
this.mStimulus = stimulus;
this.mRecordingStart = recordingTimestamp;
this.mCategory = row[this.cCategory];
this.mAoi = aoi;
this.mRecordingLast = recordingTimestamp;
return previousSegment;
}
/**
* @group Deserialization
* @description Checks if a row is empty based on the Category column.
* @param {string[]} row - Row of data.
* @returns {boolean} True if the row is empty, false otherwise.
*/
isEmptyRow = (row) => {
return row[this.cCategory] === '';
};
/**
* @group Deserialization
* @description Checks if a row is still part of the same segment as the previous row.
* @param {string[]} row - Row of data.
* @returns {boolean} True if the row is part of the same segment, false otherwise.
*/
isSameSegment(row) {
return row[this.cEyeMovementTypeIndex] === this.mEyeMovementTypeIndex;
}
/**
* @group Deserialization
* @description Finalizes the deserialization process. Releases the last segment. Used when there is no more data to deserialize.
* @returns {SingleDeserializerOutput | null} Deserialized data.
*/
finalize() {
return this.getPreviousSegment();
}
/**
* @group Deserialization
* @description Releases the last segment. Used either when there is no more data to deserialize or when a new segment is encountered.
* @returns {SingleDeserializerOutput | null} Deserialized data.
*/
getPreviousSegment() {
if (this.mParticipant === '' ||
this.mStimulus === '' ||
this.mRecordingStart === '' ||
this.mRecordingLast === this.mRecordingStart)
return null;
const baseTime = this.stimuliBaseTimes.get(this.mStimulus + this.mParticipant);
return {
stimulus: this.mStimulus,
participant: this.mParticipant,
start: String((Number(this.mRecordingStart) - Number(baseTime)) *
TobiiEyeDeserializer.TIME_MODIFIER),
end: String((Number(this.mRecordingLast) - Number(baseTime)) *
TobiiEyeDeserializer.TIME_MODIFIER),
category: this.mCategory,
aoi: this.mAoi,
};
}
/**
* @group Deserialization
* @description Extracts AOIs from a row of data. Iterates over the array of AOI information objects to find active AOIs.
* @param {string[]} row - Row of data.
* @returns {string[]} Array of AOIs.
* @example ['AOI_1', 'AOI_2']
*/
getAoisFromRow(row) {
return this.cAoiInfo
.filter(aoiInfo => row[aoiInfo.columnPosition] === '1')
.map(aoiInfo => aoiInfo.aoiName);
}
}