diff --git a/examples/example-components.html b/examples/example-components.html
new file mode 100644
index 0000000..17680f2
--- /dev/null
+++ b/examples/example-components.html
@@ -0,0 +1,66 @@
+
+
+
+ An Eyetracking Experiment
+
+
+
+
+
diff --git a/examples/pseudo-components.html b/examples/pseudo-components.html
new file mode 100644
index 0000000..62451f6
--- /dev/null
+++ b/examples/pseudo-components.html
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
diff --git a/examples/test-components.html b/examples/test-components.html
new file mode 100644
index 0000000..8f769bb
--- /dev/null
+++ b/examples/test-components.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Components.ts b/src/Components.ts
new file mode 100644
index 0000000..1b3c25b
--- /dev/null
+++ b/src/Components.ts
@@ -0,0 +1,390 @@
+import { Eyetracker } from "./Eyetracker";
+
+/** The properties of any fixation that will be drawn. */
+type fixationProps = {
+ /** The diameter of the fixation used. */
+ diameter: string;
+ /** The color of the fixation used. */
+ color: string;
+ /** The function of the fixation used to generate it. */
+ shape: fixationShapeFunction;
+ /** The thickness of a cross fixation. */
+ crossThickness: string;
+};
+
+/** The function type used by {@link calibrate()} in order to draw a fixation. */
+type fixationShapeFunction = (x: number, y: number) => HTMLElement;
+
+export class Components {
+ /** The eyetracker object that will be used to carry out most underlying work. */
+ private Eyetracker: Eyetracker | undefined;
+ /** The calibration div used in the {@link calibrate()} function. */
+ private calDivUsed: HTMLDivElement | undefined;
+ /** The properties of the fixation that will be created by {@link drawFixation()}. */
+ private props: fixationProps = {
+ diameter: "10px",
+ color: "red",
+ shape: this.drawCircleFixation,
+ crossThickness: "4px",
+ };
+
+ /** The default message that will be used for the landing page. */
+ private DEFAULT_MESSAGE = `Welcome to an experiment that uses eye tracking.
+ In a few moments you will be asked for permission to use your camera in order to complete the experiment.`;
+ /** The default 9 points that will be used for calibration. */
+ private DEFAULT_POINTS: Array> = [
+ [10, 10],
+ [10, 50],
+ [10, 90],
+ [50, 10],
+ [50, 50],
+ [50, 90],
+ [90, 10],
+ [90, 50],
+ [90, 90],
+ ];
+
+ /**
+ * @constructor
+ * @param et The eyetracker object that will be used to carry out most underlying work.
+ */
+ constructor(et: Eyetracker) {
+ if (et === undefined || et === null) {
+ throw new Error("Eyetracker cannot be undefined or null.");
+ }
+ this.Eyetracker = et;
+ }
+
+ /**
+ * Initializes core components of the Eyetracker class. This must be called AFTER the
+ * camera is selected, either through the {@link createSelector()} function or
+ * by manually setting the camera.
+ * @returns An object containing the Eyetracker's detector, video, and canvas.
+ */
+ async preload(): Promise {
+ let detector = await this.Eyetracker!.init();
+ let video = await this.Eyetracker!.createVideo();
+ let canvas = this.Eyetracker!.createDisplayCanvas();
+ return { detector, video, canvas };
+ }
+
+ //TODO: maybe allow multiple lines instead of forcing user to put one big one?
+ /**
+ * This will create a landing page that will inform the user of the nature
+ * of the usage of the Eyetracking software. The landing page is contained
+ * inside of a wrapper div with flex capabilities.
+ *
+ * @param id The id of the div that will be used to display the landing page.
+ * @param message The message that will be displayed on the landing page.
+ * @returns The landing page div.
+ */
+ createLanding(
+ id: string = "landing",
+ message: string = this.DEFAULT_MESSAGE
+ ): HTMLDivElement {
+ let landing = document.createElement("div");
+ landing.id = id;
+ landing.classList.add("landing");
+ landing.innerHTML = `${message}
`;
+
+ let wrapper = document.createElement("div");
+ wrapper.id = `${id}-wrapper`;
+ wrapper.append(landing);
+ wrapper.style.display = "flex";
+ wrapper.style.flexDirection = "column";
+ wrapper.style.textAlign = "center";
+
+ return wrapper;
+ }
+
+ /**
+ * This will create a selector that will allow for one to select a camera.
+ * This will automatically ask for permission and once finished, use
+ * {@link Eyetracker.setCamera()}.
+ *
+ * @param id The id of the selector.
+ * @param message The message that will be displayed on the button.
+ * @returns An array containing the selector and the button, in that order.
+ */
+ async createSelector(
+ id: string = "selector",
+ message: string = "Select a camera"
+ ): Promise> {
+ let selector = document.createElement("select");
+ selector.id = id;
+
+ await this.Eyetracker!.getCameraPermission();
+ const devices = await this.Eyetracker!.getListOfCameras();
+
+ if (devices.length !== 1) {
+ const blank = document.createElement("option");
+ blank.style.display = "none";
+ selector.appendChild(blank);
+ }
+
+ for (const d of devices) {
+ let option = document.createElement("option");
+ option.value = d.deviceId;
+ option.innerHTML = d.label;
+ selector.appendChild(option);
+ }
+
+ const btn = document.createElement("button");
+ btn.id = `${id}-btn`;
+ btn.innerHTML = `${message}`;
+ btn.addEventListener("click", async () => {
+ const cam = selector.options[selector.selectedIndex].value;
+ const selectedCamera = devices.filter((d) => d.deviceId === cam)[0];
+ await this.Eyetracker!.setCamera(selectedCamera);
+ selector.remove();
+ btn.remove();
+ });
+ return new Array(selector, btn);
+ }
+
+ /**
+ * Calibrate the eyetracker. This will automatically cycle through the points
+ * in 3s intervals, taking facial landmark data 1.5s into the point's appearance.
+ *
+ * PLEASE NOTE: To increase calibration accuracy, use more than 4 points. 9 is preferred.
+ *
+ * @param div The div that will be used to display the calibration.
+ * @param time The time between each point in the calibration process, with data collected at
+ * the middle of each interval. Default is 3s.
+ * @param points A list of points denoted in absolute coordinates that will be used in calibration.
+ * @returns An object containing the x and y coordinates of the calibration point,
+ * along with associated facial landmark data.
+ */
+ async calibrate(
+ div: HTMLDivElement,
+ time: number = 3000,
+ points: Array> = this.DEFAULT_POINTS
+ ): Promise> {
+ if (div === null) {
+ throw new Error("Please provide a div.");
+ } else {
+ div.innerHTML = "";
+ }
+ this.calDivUsed = div;
+
+ let finishedPoints: Array = [];
+
+ if (points.length < 4) {
+ console.warn(
+ "There are not a lot of points to calibrate, consider adding more or using the DEFAULT_POINTS."
+ );
+ }
+ await this.Eyetracker!.initVideoFrameLoop();
+
+ let calibrateLoop = setInterval(async () => {
+ div.innerHTML = "";
+ let point = points.shift();
+ if (point === undefined) {
+ clearInterval(calibrateLoop);
+ //TODO- Update this to return what exactly we need for the neural network.
+ console.log(await this.Eyetracker!.processCalibrationPoints()); // facial landmarks
+ return finishedPoints; // imageData + onset time
+ }
+ this.drawFixation(point[0], point[1], div);
+ let onsetTime = performance.now();
+
+ setTimeout(async () => {
+ //TODO- eventually we must update the API so that we can just gather the facial
+ //landmarks/image data using a field in eyetracker.
+ await this.Eyetracker!.detectFace();
+ let currentPoint: any = this.Eyetracker!.calibratePoint(
+ point![0],
+ point![1]
+ );
+ //TODO- let's find a way to prevent throwing this error, maybe explicitly defining?
+ // fixed with the :any cast, but we should still consider implementing types.
+ currentPoint.onsetTime = onsetTime;
+ finishedPoints.push(currentPoint);
+ }, time / 2);
+ }, time);
+ return finishedPoints;
+ }
+
+ /**
+ * This will create a fixation based off of the given coordinates, placing it in the div.
+ * The properties of the fixation are customizable through the {@link props} field.
+ *
+ * @param x The x coordinate of the fixation.
+ * @param y The y coordinate of the fixation.
+ * @param div The div that the fixation will be placed in.
+ */
+ drawFixation(x: number, y: number, div: HTMLDivElement) {
+ if (div === null || div === undefined) {
+ throw new Error("Please provide a div to draw the fixation in.");
+ }
+ if (div.parentElement === null || div.parentElement === undefined) {
+ throw new Error("Div cannot be document.body.");
+ }
+
+ let fixation = this.props.shape.bind(this)(x, y);
+ div.appendChild(fixation);
+ }
+
+ /**
+ * Creates a circle fixation centered absolutely on the given coordinates.
+ *
+ * @param x The x coordinate of the fixation.
+ * @param y The y coordinate of the fixation.
+ * @returns A circle fixation element.
+ */
+ drawCircleFixation(x: number, y: number) {
+ const adjustCircle = parseInt(this.props.diameter) / 2;
+ const circle = document.createElement("div");
+ circle.style.width = this.props.diameter;
+ circle.style.height = this.props.diameter;
+ circle.style.borderRadius = "50%";
+ circle.style.backgroundColor = this.props.color;
+ circle.style.position = "absolute";
+ circle.style.left = `calc(${x}% - ${adjustCircle}px)`;
+ circle.style.top = `calc(${y}% - ${adjustCircle}px)`;
+ return circle;
+ }
+
+ /**
+ * Creates a cross (+) fixation centered absolutely on the given coordinates.
+ *
+ * @param x The x coordinate of the fixation.
+ * @param y The y coordinate of the fixation.
+ * @returns A cross fixation element.
+ */
+ drawCrossFixation(x: number, y: number) {
+ const adjustCross = parseInt(this.props.crossThickness) / 2;
+ const cross1 = document.createElement("div");
+ cross1.style.width = this.props.crossThickness;
+ cross1.style.height = this.props.diameter;
+ cross1.style.backgroundColor = this.props.color;
+ cross1.style.position = "absolute";
+ cross1.style.left = `calc(${x}% - ${adjustCross}px)`;
+ cross1.style.top = `calc(${y}% - ${parseInt(this.props.diameter) / 2}px)`;
+
+ const cross2 = document.createElement("div");
+ cross2.style.width = this.props.diameter;
+ cross2.style.height = this.props.crossThickness;
+ cross2.style.backgroundColor = this.props.color;
+ cross2.style.position = "absolute";
+ cross2.style.left = `calc(${x}% - ${parseInt(this.props.diameter) / 2}px)`;
+ cross2.style.top = `calc(${y}% - ${adjustCross}px)`;
+
+ const wrapper = document.createElement("div");
+ wrapper.appendChild(cross1);
+ wrapper.appendChild(cross2);
+ return wrapper;
+ }
+
+ /**
+ * Creates a square fixation centered absolutely on the given coordinates.
+ *
+ * @param x The x coordinate of the fixation.
+ * @param y The y coordinate of the fixation.
+ * @returns A square fixation element.
+ */
+ drawSquareFixation(x: number, y: number) {
+ const adjustSquare = parseInt(this.props.diameter) / 2;
+ const square = document.createElement("div");
+ square.style.width = this.props.diameter;
+ square.style.height = this.props.diameter;
+ square.style.borderRadius = "50%";
+ square.style.backgroundColor = this.props.color;
+ square.style.position = "absolute";
+ square.style.left = `calc(${x}% - ${adjustSquare}px)`;
+ square.style.top = `calc(${y}% - ${adjustSquare}px)`;
+ return square;
+ }
+
+ /**
+ * Creates a reticule fixation centered absolutely on the given coordinates.
+ *
+ * @param x The x coordinate of the fixation.
+ * @param y The y coordinate of the fixation.
+ * @returns A reticule fixation element.
+ */
+ drawReticuleFixation(x: number, y: number) {
+ if (parseInt(this.props.diameter) <= 10) {
+ console.warn("Reticule diameter is small and will look odd.");
+ }
+ const adjustReticule = parseInt(this.props.diameter) / 2;
+ const outerCircle = document.createElement("div");
+ outerCircle.style.width = this.props.diameter;
+ outerCircle.style.height = this.props.diameter;
+ outerCircle.style.borderRadius = "50%";
+ outerCircle.style.backgroundColor = this.props.color;
+ outerCircle.style.position = "absolute";
+ outerCircle.style.left = `calc(${x}% - ${adjustReticule}px)`;
+ outerCircle.style.top = `calc(${y}% - ${adjustReticule}px)`;
+ outerCircle.style.zIndex = "1";
+
+ const adjustInner = adjustReticule / 2;
+ const innerCircle = document.createElement("div");
+ const innerSize = `${parseInt(this.props.diameter) / 2}px`;
+ innerCircle.style.width = innerSize;
+ innerCircle.style.height = innerSize;
+ innerCircle.style.borderRadius = "50%";
+ innerCircle.style.backgroundColor = this.props.color;
+ innerCircle.style.position = "absolute";
+ innerCircle.style.left = `calc(${x}% - ${adjustInner}px)`;
+ innerCircle.style.top = `calc(${y}% - ${adjustInner}px)`;
+ innerCircle.style.zIndex = "3";
+
+ const eraseColor = "white";
+ const eraseThickness = parseInt(this.props.diameter) / 4;
+ const eraseSize = `${eraseThickness}px`;
+ const adjustErase = eraseThickness / 2;
+
+ const eCross1 = document.createElement("div");
+ eCross1.style.width = eraseSize;
+ eCross1.style.height = this.props.diameter;
+ eCross1.style.backgroundColor = eraseColor;
+ eCross1.style.position = "absolute";
+ eCross1.style.left = `calc(${x}% - ${adjustErase}px)`;
+ eCross1.style.top = `calc(${y}% - ${parseInt(this.props.diameter) / 2}px)`;
+ eCross1.style.zIndex = "2";
+
+ const eCross2 = document.createElement("div");
+ eCross2.style.width = this.props.diameter;
+ eCross2.style.height = eraseSize;
+ eCross2.style.backgroundColor = eraseColor;
+ eCross2.style.position = "absolute";
+ eCross2.style.left = `calc(${x}% - ${parseInt(this.props.diameter) / 2}px)`;
+ eCross2.style.top = `calc(${y}% - ${adjustErase}px)`;
+ eCross2.style.zIndex = "2";
+
+ const wrapper = document.createElement("div");
+ wrapper.appendChild(outerCircle);
+ wrapper.appendChild(innerCircle);
+ wrapper.appendChild(eCross1);
+ wrapper.appendChild(eCross2);
+ return wrapper;
+ }
+
+ //TODO- update this if we need it or not
+ /**
+ * This is a helper function that will translate given absolute coordinates to standard coordinates.
+ * @param points Points in absolute coordinates, generated by the {@link calibrate()} function.
+ * @returns
+ */
+ translateToStandard(points: Array): Array {
+ if (this.calDivUsed === null || this.calDivUsed === undefined) {
+ throw new Error(
+ "No calibration div was used, please use the calibrate function."
+ );
+ }
+ let newPoints: Array = [];
+ points.forEach((p) => {
+ let newPoint = {
+ //@ts-ignore
+ x: p.x * this.calDivUsed!.offsetWidth,
+ //@ts-ignore
+ y: p.y * this.calDivUsed!.offsetWidth,
+ //@ts-ignore
+ onsetTime: p.onsetTime,
+ }; // TODO- we should really set a type to these points
+ newPoints.push(newPoint);
+ });
+ return newPoints;
+ }
+}
diff --git a/src/Eyetracker.ts b/src/Eyetracker.ts
index 101d4dc..03c45f7 100644
--- a/src/Eyetracker.ts
+++ b/src/Eyetracker.ts
@@ -25,14 +25,14 @@ export class Eyetracker {
private model: MediaPipeFaceMesh | undefined;
/** Shows whether or not the overlay should be displayed. */
private overlay: boolean = true;
- /** */
+ /** The frames, timestamped with rVF callbacks, generated by {@link initVideoFrameLoop()} */
private frames: Array<{
imageData: ImageData;
timestamp: DOMHighResTimeStamp;
}> = [];
/** */
public onFrameUpdateCallbackList: Array = [];
- /** */
+ /** Determines whether or not {@link initVideoFrameLoop()}'s rVFCallbacks are paused or not. */
public frameUpdatePaused: boolean = false;
/**
@@ -101,11 +101,14 @@ export class Eyetracker {
* @param Id An id attribute to which can be used to identify the video later
* @returns An HTMLVideoElement whose source is the stream from the previously selected camera
*/
- async createVideo(Id: string): Promise {
+ async createVideo(Id: string = "et-video"): Promise {
let video: HTMLVideoElement = document.createElement("video");
video.setAttribute("id", Id);
video.style.transform = "scaleX(-1)";
- (video.srcObject as undefined | MediaProvider | null) = this.stream;
+ if (this.stream === null || this.stream === undefined) {
+ throw new Error("No camera stream detected, have you run setCamera()?");
+ }
+ video.srcObject = this.stream;
video.autoplay = true;
return new Promise((resolve) => {
video.onloadedmetadata = () => {
@@ -122,28 +125,28 @@ export class Eyetracker {
* @param Id An id attribute to which can be used to identify the video later
* @returns A mirrored HTMLCanvasElement with matching proportions to the created video
*/
- createDisplayCanvas(Id: string): HTMLCanvasElement | undefined {
+ createDisplayCanvas(Id: string = "et-canvas"): HTMLCanvasElement | undefined {
+ if (this.video === null || this.video === undefined) {
+ throw new Error("No video stream detected, have you run createVideo()?");
+ }
+
let canvas: HTMLCanvasElement = document.createElement("canvas");
canvas.setAttribute("id", Id);
this.canvas = canvas;
- if (this.video != null) {
- canvas.height = this.video.height;
- canvas.width = this.video.width;
- this.canvas = canvas;
- var ctx: CanvasRenderingContext2D | null = canvas.getContext("2d");
- if (ctx != null) {
- ctx.translate(canvas.width, 0);
- ctx.scale(-1, 1);
- ctx.fillStyle = "green";
- (this.ctx as undefined | CanvasRenderingContext2D | null) = ctx;
- return canvas;
- } else {
- console.log("canvas.getContext('2d') return null");
- return canvas;
- }
- } else {
- console.log('Undefined Property "this.video"');
+ canvas.height = this.video.height;
+ canvas.width = this.video.width;
+ this.canvas = canvas;
+ var ctx: CanvasRenderingContext2D | null = canvas.getContext("2d");
+ if (ctx === null) {
+ throw new Error(
+ "Could not create canvas context, have you already declared ctx with a different type of context?"
+ );
}
+ ctx.translate(canvas.width, 0);
+ ctx.scale(-1, 1);
+ ctx.fillStyle = "green";
+ this.ctx = ctx;
+ return canvas;
}
// Need to test this out
@@ -152,21 +155,29 @@ export class Eyetracker {
* @param canvas An existing canvas whose proprotions should match the video
*/
setDisplayCanvas(canvas: HTMLCanvasElement): void {
- let video: HTMLVideoElement | undefined = this.video;
+ if (this.video === null || this.video === undefined) {
+ throw new Error("No video stream detected, have you run createVideo()?");
+ }
+ if (this.canvas === null || this.canvas === undefined) {
+ throw new Error(
+ "No canvas detected, have you run createDisplayCanvas()?"
+ );
+ }
+
+ let video = this.video;
this.canvas = canvas;
- if (canvas != undefined && video != undefined) {
- canvas.height = video.height;
- canvas.width = video.width;
- var ctx: CanvasRenderingContext2D | null = canvas.getContext("2d");
- if (ctx != null) {
- ctx.translate(canvas.width, 0);
- ctx.scale(-1, 1);
- ctx.fillStyle = "green";
- }
- (this.ctx as undefined | CanvasRenderingContext2D | null) = ctx;
- } else {
- console.log('/"this.canvas/", /"this.video/" Undefined');
+ canvas.height = video.height;
+ canvas.width = video.width;
+ var ctx: CanvasRenderingContext2D | null = canvas.getContext("2d");
+ if (ctx === null || ctx === undefined) {
+ throw new Error(
+ "Could not create canvas context, have you already declared ctx with a different type of context?"
+ );
}
+ ctx.translate(canvas.width, 0);
+ ctx.scale(-1, 1);
+ ctx.fillStyle = "green";
+ this.ctx = ctx;
}
/**
@@ -179,11 +190,16 @@ export class Eyetracker {
video: HTMLVideoElement = this.video!
): void {
let ctx: CanvasRenderingContext2D | null = canvas.getContext("2d");
- if (ctx != undefined && video != undefined) {
- ctx.drawImage(video, 0, 0);
- } else {
- console.log('"this.ctx", "this.video" Undefined');
+ if (ctx === null || ctx === undefined) {
+ throw new Error(
+ "Could not create canvas context, have you already declared ctx with a different type of context?"
+ );
}
+ if (video === null || video === undefined) {
+ throw new Error("No video stream detected, have you run createVideo()?");
+ }
+
+ ctx.drawImage(video, 0, 0);
}
/**
@@ -227,6 +243,7 @@ export class Eyetracker {
},
canvas: HTMLCanvasElement = this.canvas!
): void {
+ // ?? why is this in a try-catch?
try {
let ctx: CanvasRenderingContext2D | null = canvas.getContext("2d");
@@ -315,7 +332,7 @@ export class Eyetracker {
return point;
}
- async processCalibrationPoints(): Promise {
+ async processCalibrationPoints(): Promise> {
let processedPoints = [];
for (let i = 0; i < this.calibrationPoints.length; i++) {
let predictions = await this.model!.estimateFaces({
@@ -330,6 +347,7 @@ export class Eyetracker {
});
}
this.processedCalibrationPoints = processedPoints;
+ return processedPoints;
}
/**
@@ -376,7 +394,6 @@ export class Eyetracker {
canvas: HTMLCanvasElement = this.canvas!
) {
let ctx = canvas.getContext("2d");
- let frames = this.frames;
let Eyetracker = this;
async function repeatDetection(now: DOMHighResTimeStamp, metadata: object) {
if (!Eyetracker.frameUpdatePaused) {
diff --git a/src/index.ts b/src/index.ts
index d85c202..931518e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,10 @@
import { Eyetracker } from "./Eyetracker";
+import { Components } from "./Components";
+/**
+ * Create a new instance of the eye tracker.
+ * @returns A new instance of the eye tracker.
+ */
export function initEyetracker() {
if (!("requestVideoFrameCallback" in HTMLVideoElement.prototype)) {
window.alert(
@@ -8,3 +13,12 @@ export function initEyetracker() {
}
return new Eyetracker();
}
+
+/**
+ * Creates an instance of a components module.
+ * @param et The eye tracker instance.
+ * @returns A new instance of the components class.
+ */
+export function initComponents(et: Eyetracker) {
+ return new Components(et);
+}
diff --git a/tests/components.test.ts b/tests/components.test.ts
new file mode 100644
index 0000000..bb8516f
--- /dev/null
+++ b/tests/components.test.ts
@@ -0,0 +1,22 @@
+import { initEyetracker, initComponents } from "../src/index";
+
+describe("components", () => {
+ let eye: any;
+ let comp: any;
+
+ beforeEach(() => {
+ global.alert = jest.fn();
+ eye = initEyetracker();
+ comp = initComponents(eye);
+ });
+
+ test("landing page generated and customizable", () => {
+ document.body.innerHTML = `
`;
+ let landing = comp.createLanding("test-land", "Landing page.");
+ document.getElementById("test")!.appendChild(landing);
+
+ let innerLanding = document.getElementById("test-land");
+ expect(innerLanding).toBeTruthy();
+ expect(innerLanding!.innerHTML).toEqual(`Landing page.
`);
+ });
+});
diff --git a/tests/index.test.ts b/tests/index.test.ts
index 9973466..4966a4a 100644
--- a/tests/index.test.ts
+++ b/tests/index.test.ts
@@ -1,6 +1,7 @@
-import { initEyetracker } from "../src/index";
+import { initEyetracker, initComponents } from "../src/index";
test("test adds two numbers", () => {
+ global.alert = jest.fn(); // TODO: figure out a way to implement alert or just remove it!
const eye = initEyetracker();
expect(eye.add(2, 2)).toEqual(4);
});