@adriansteffan/reactive 0.0.26 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,178 @@
1
+ export type Store = Record<string, any>;
2
+
3
+ type BaseTrialData = {
4
+ index: number;
5
+ trialNumber: number;
6
+ start: number;
7
+ end: number;
8
+ duration: number;
9
+ };
10
+
11
+ export type ComponentResultData = BaseTrialData & {
12
+ type: string;
13
+ name: string;
14
+ responseData?: any;
15
+ };
16
+
17
+ export type CanvasResultData = BaseTrialData & {
18
+ metadata?: Record<string, any>;
19
+ key: string | null;
20
+ reactionTime: number | null;
21
+ };
22
+
23
+ export type RefinedTrialData = ComponentResultData | CanvasResultData;
24
+
25
+ export interface MarkerItem { type: 'MARKER'; id: string; }
26
+
27
+ export type ConditionalFunction = (data?: RefinedTrialData[], store?: Store) => boolean;
28
+
29
+ export type StoreUpdateFunction = (data?: RefinedTrialData[], store?: Store) => Record<string, any>;
30
+
31
+ export interface IfGotoItem { type: 'IF_GOTO'; cond: ConditionalFunction; marker: string; }
32
+ export interface UpdateStoreItem { type: 'UPDATE_STORE'; fun: StoreUpdateFunction; }
33
+ export interface IfBlockItem { type: 'IF_BLOCK'; cond: ConditionalFunction; timeline: TimelineItem[]; }
34
+ export interface WhileBlockItem { type: 'WHILE_BLOCK'; cond: ConditionalFunction; timeline: TimelineItem[]; }
35
+ export type ControlFlowItem = MarkerItem | IfGotoItem | UpdateStoreItem | IfBlockItem | WhileBlockItem;
36
+ export type TimelineItem = ControlFlowItem | any;
37
+
38
+ export interface ExecuteContentInstruction { type: 'ExecuteContent'; content: any; }
39
+ export interface IfGotoInstruction {
40
+ type: 'IfGoto';
41
+ cond: (store: Store, data: RefinedTrialData[]) => boolean;
42
+ marker: string;
43
+ }
44
+ export interface UpdateStoreInstruction {
45
+ type: 'UpdateStore';
46
+ fun: (store: Store, data: RefinedTrialData[]) => Store;
47
+ }
48
+ export type UnifiedBytecodeInstruction = ExecuteContentInstruction | IfGotoInstruction | UpdateStoreInstruction;
49
+
50
+ function prefixUserMarkers(marker: string): string {
51
+ return `user_${marker}`;
52
+ }
53
+
54
+ export function compileTimeline(
55
+ timeline: TimelineItem[]
56
+ ): {
57
+ instructions: UnifiedBytecodeInstruction[];
58
+ markers: { [key: string]: number };
59
+ } {
60
+ const instructions: UnifiedBytecodeInstruction[] = [];
61
+ const markers: { [key: string]: number } = {};
62
+ let uniqueMarkerCounterForThisRun = 0;
63
+
64
+ function getUniqueMarker(prefix: string): string {
65
+ return `${prefix}_auto_${uniqueMarkerCounterForThisRun++}`;
66
+ }
67
+
68
+ function adaptCondition(
69
+ userCondition: ConditionalFunction
70
+ ): (store: Store, data: RefinedTrialData[]) => boolean {
71
+ return (runtimeStore: Store, runtimeData: RefinedTrialData[]): boolean => {
72
+ return userCondition(runtimeData, runtimeStore);
73
+ };
74
+ }
75
+
76
+ function adaptUpdate(
77
+ userUpdateFunction: StoreUpdateFunction
78
+ ): (store: Store, data: RefinedTrialData[]) => Store {
79
+ return (runtimeStore: Store, runtimeData: RefinedTrialData[]): Store => {
80
+ const updates = userUpdateFunction(runtimeData, runtimeStore);
81
+ if (typeof updates === 'object' && updates !== null) {
82
+ return {
83
+ ...runtimeStore,
84
+ ...updates,
85
+ };
86
+ } else {
87
+ console.warn("Store update function did not return an object. Store remains unchanged.", { data: runtimeData, store: runtimeStore });
88
+ return runtimeStore;
89
+ }
90
+ };
91
+ }
92
+
93
+ function processTimeline(items: TimelineItem[]) {
94
+ for (const item of items) {
95
+ let isControlFlow = false;
96
+
97
+ if (typeof item === 'object' && item !== null && 'type' in item) {
98
+ const itemType = item.type;
99
+
100
+ switch (itemType) {
101
+ case 'MARKER': {
102
+ const markerItem = item as MarkerItem;
103
+ markers[prefixUserMarkers(markerItem.id)] = instructions.length;
104
+ isControlFlow = true;
105
+ break;
106
+ }
107
+ case 'IF_GOTO': {
108
+ const ifGotoItem = item as IfGotoItem;
109
+ const runtimeConditionFunc = adaptCondition(ifGotoItem.cond);
110
+ instructions.push({
111
+ type: 'IfGoto',
112
+ cond: runtimeConditionFunc,
113
+ marker: prefixUserMarkers(ifGotoItem.marker),
114
+ });
115
+ isControlFlow = true;
116
+ break;
117
+ }
118
+ case 'UPDATE_STORE': {
119
+ const updateStoreItem = item as UpdateStoreItem;
120
+ const runtimeUpdateFunc = adaptUpdate(updateStoreItem.fun);
121
+ instructions.push({
122
+ type: 'UpdateStore',
123
+ fun: runtimeUpdateFunc,
124
+ });
125
+ isControlFlow = true;
126
+ break;
127
+ }
128
+ case 'IF_BLOCK': {
129
+ const ifBlockItem = item as IfBlockItem;
130
+ const endMarker = getUniqueMarker('if_end');
131
+ const runtimeConditionFunc = adaptCondition(ifBlockItem.cond);
132
+ instructions.push({
133
+ type: 'IfGoto',
134
+ cond: (store, data) => !runtimeConditionFunc(store, data),
135
+ marker: endMarker,
136
+ });
137
+ processTimeline(ifBlockItem.timeline);
138
+ markers[endMarker] = instructions.length;
139
+ isControlFlow = true;
140
+ break;
141
+ }
142
+ case 'WHILE_BLOCK': {
143
+ const whileBlockItem = item as WhileBlockItem;
144
+ const startMarker = getUniqueMarker('while_start');
145
+ const endMarker = getUniqueMarker('while_end');
146
+ const runtimeConditionFunc = adaptCondition(whileBlockItem.cond);
147
+ markers[startMarker] = instructions.length;
148
+ instructions.push({
149
+ type: 'IfGoto',
150
+ cond: (store, data) => !runtimeConditionFunc(store, data),
151
+ marker: endMarker,
152
+ });
153
+ processTimeline(whileBlockItem.timeline);
154
+ instructions.push({
155
+ type: 'IfGoto',
156
+ cond: () => true,
157
+ marker: startMarker,
158
+ });
159
+ markers[endMarker] = instructions.length;
160
+ isControlFlow = true;
161
+ break;
162
+ }
163
+ }
164
+ }
165
+
166
+ if (!isControlFlow) {
167
+ instructions.push({
168
+ type: 'ExecuteContent',
169
+ content: item,
170
+ });
171
+ }
172
+ }
173
+ }
174
+
175
+ processTimeline(timeline);
176
+
177
+ return { instructions, markers };
178
+ }
@@ -1,16 +1,17 @@
1
- import { Capacitor } from "@capacitor/core";
1
+ import { Capacitor } from '@capacitor/core';
2
2
 
3
- export function now() {
3
+
4
+ export function now(){
4
5
  return Math.round(performance.now());
5
6
  }
6
7
 
7
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
- export function shuffle(array: any[]) {
9
- for (let i = array.length - 1; i >= 0; i--) {
10
- const j = Math.floor(Math.random() * (i + 1));
11
- [array[i], array[j]] = [array[j], array[i]];
12
- }
13
- return array;
8
+ export function isFullscreen(): boolean {
9
+ return !!(
10
+ document.fullscreenElement ||
11
+ (document as any).webkitFullscreenElement ||
12
+ (document as any).mozFullScreenElement ||
13
+ (document as any).msFullscreenElement
14
+ );
14
15
  }
15
16
 
16
17
  export function isDesktop() {
@@ -20,10 +21,10 @@ export function isDesktop() {
20
21
  // Generic type for all data structures
21
22
  export interface TrialData {
22
23
  index: number;
23
- trialNumber: number,
24
+ trialNumber: number;
24
25
  type: string;
25
26
  name: string;
26
- data: any;
27
+ responseData: any;
27
28
  start: number;
28
29
  end: number;
29
30
  duration: number;
@@ -32,55 +33,61 @@ export interface TrialData {
32
33
  export interface FileUpload {
33
34
  filename: string;
34
35
  content: string;
35
- encoding: 'base64' | 'utf8';
36
+ encoding?: 'base64' | 'utf8';
36
37
  }
37
38
 
38
39
  export interface ExperimentConfig {
39
40
  showProgressBar: boolean;
40
41
  }
41
42
 
42
- export interface Store { [key: string]: any }
43
+ export interface Store {
44
+ [key: string]: any;
45
+ }
43
46
 
44
47
  export interface BaseComponentProps {
45
- next: (data: object) => void;
48
+ next: (data?: object, actualStartTime?: number, actualStopTime?: number) => void;
46
49
  data: TrialData[];
47
50
  store?: Store;
48
51
  updateStore: (mergeIn: Store) => void;
49
52
  }
50
53
 
51
54
  type ParamType = 'string' | 'number' | 'boolean' | 'array' | 'json';
55
+
52
56
  type ParamValue<T extends ParamType> = T extends 'number'
53
- ? number | undefined
57
+ ? number
54
58
  : T extends 'boolean'
55
- ? boolean | undefined
59
+ ? boolean
56
60
  : T extends 'array' | 'json'
57
- ? any | undefined
58
- : string | undefined;
61
+ ? any
62
+ : string;
63
+
64
+ const sharedRegistry: any[] = [];
59
65
 
60
66
  export function getParam<T extends ParamType>(
61
67
  name: string,
62
- defaultValue: ParamValue<T> | undefined,
68
+ defaultValue: ParamValue<T>,
63
69
  type: T = 'string' as T,
64
- ): ParamValue<T> | undefined {
65
- // First, check for the parameter in the base64-encoded JSON
66
- const encodedJson = new URLSearchParams(window.location.search).get('_b');
67
- if (encodedJson) {
68
- try {
69
- const jsonString = atob(encodedJson);
70
- const decodedParams = JSON.parse(jsonString);
71
- if (name in decodedParams) {
72
- return decodedParams[name];
73
- }
74
- } catch {
75
- // Silently fail if decoding or parsing fails, fallthrough to lower case
76
- }
70
+ description?: string,
71
+ ): ParamValue<T> {
72
+ let registryEntry = sharedRegistry.find((p) => p.name === name);
73
+
74
+ if (!registryEntry) {
75
+ registryEntry = {
76
+ name,
77
+ defaultValue,
78
+ type,
79
+ description,
80
+ value: undefined,
81
+ };
82
+ sharedRegistry.push(registryEntry);
77
83
  }
78
84
 
79
- //Next, check for the parameter directly in the URL
80
- // since this does not have the automatic type conversions of JSON.parse, we have to create helper functionss
81
85
  const conversions: Record<ParamType, (v: string) => any> = {
82
86
  string: (v) => v,
83
- number: (v) => Number(v) || defaultValue,
87
+ number: (v) => {
88
+ const num = Number(v);
89
+ return isNaN(num) ? defaultValue : num;
90
+ },
84
91
  boolean: (v) => v.toLowerCase() === 'true',
85
92
  array: (v) => {
86
93
  try {
@@ -98,7 +105,7 @@ export function getParam<T extends ParamType>(
98
105
  },
99
106
  };
100
107
 
101
- const convertValue = (value: any): ParamValue<T> | undefined => {
108
+ const convertValue = (value: any): ParamValue<T> => {
102
109
  if (
103
110
  (type === 'string' && typeof value === 'string') ||
104
111
  (type === 'number' && typeof value === 'number') ||
@@ -110,19 +117,52 @@ export function getParam<T extends ParamType>(
110
117
  }
111
118
 
112
119
  if (typeof value === 'string') {
113
- if (value.toLowerCase() === 'undefined') return undefined;
120
+ if (value.toLowerCase() === 'undefined') return defaultValue;
114
121
  return conversions[type](value);
115
122
  }
116
123
 
117
124
  return defaultValue;
118
125
  };
119
126
 
127
+ // First, check for the parameter in the base64-encoded JSON
128
+ const encodedJson = new URLSearchParams(window.location.search).get('_b');
129
+ if (encodedJson) {
130
+ try {
131
+ const jsonString = atob(encodedJson);
132
+ const decodedParams = JSON.parse(jsonString);
133
+ if (name in decodedParams) {
134
+ const convertedValue = convertValue(decodedParams[name]);
135
+ registryEntry.value = convertedValue;
136
+ return convertedValue;
137
+ }
138
+ } catch {
139
+ // Silently fail if decoding or parsing fails, fallthrough to lower case
140
+ }
141
+ }
142
+
143
+ // Next, check for the parameter directly in the URL
120
144
  const value = new URLSearchParams(window.location.search).get(name);
121
- if (value === undefined || value === null) return defaultValue;
122
- return convertValue(value);
145
+ if (value === undefined || value === null) {
146
+ // If no value found, register default value
147
+ return defaultValue;
148
+ }
149
+
150
+ const convertedValue = convertValue(value);
151
+ registryEntry.value = convertedValue;
152
+ return convertedValue;
123
153
  }
124
154
 
155
+ const timelineRepresentation: { type: string; name?: string }[] = [];
125
156
 
157
+ // Param class that uses the same shared registry
158
+ export class Param {
159
+ static getRegistry() {
160
+ return [...sharedRegistry];
161
+ }
162
+ static getTimelineRepresentation() {
163
+ return [...timelineRepresentation];
164
+ }
165
+ }
126
166
 
127
167
  export type Platform = 'desktop' | 'mobile' | 'web';
128
168
 
@@ -135,3 +175,94 @@ export const getPlatform = (): Platform => {
135
175
  return 'web';
136
176
  }
137
177
  };
178
+
179
+ const providedComponentParams: Record<string, any> = {};
180
+
181
+ export function registerExperimentParams(experiment: any[]) {
182
+ experiment.forEach((item) => {
183
+ const params = providedComponentParams[item.type];
184
+ if (params) {
185
+ for (const param of params) {
186
+ if(!item.hideSettings || (item.hideSettings !== true && !item.hideSettings.includes(param.name))){
187
+ sharedRegistry.push(param);
188
+ }
189
+ }
190
+ }
191
+ });
192
+ }
193
+
194
+ export function registerComponentParams(
195
+ type: string,
196
+ params: { name: string; defaultValue: any; type: string; description?: string }[],
197
+ ) {
198
+ providedComponentParams[type] = params;
199
+ }
200
+
201
+ export function subsetExperimentByParam(experiment: any[]) {
202
+ registerExperimentParams(experiment);
203
+
204
+ timelineRepresentation.length = 0;
205
+
206
+ experiment.forEach((item) => {
207
+ timelineRepresentation.push({
208
+ type: item.type ?? 'NoTypeSpecified',
209
+ name: item.name,
210
+ });
211
+ });
212
+
213
+ const include = getParam('includeSubset', undefined);
214
+ const exclude = getParam('excludeSubset', undefined);
215
+
216
+ let experimentFiltered = [...experiment];
217
+
218
+ if (include) {
219
+ const includeItems = include.split(',');
220
+ experimentFiltered = experimentFiltered.filter((item, index) => {
221
+ const positionMatch = includeItems.some((val: string) => {
222
+ const num = parseInt(val, 10);
223
+ return !isNaN(num) && num - 1 === index;
224
+ });
225
+ const nameMatch = item.name && includeItems.includes(item.name);
226
+ return positionMatch || nameMatch;
227
+ });
228
+ }
229
+
230
+ if (exclude) {
231
+ const excludeItems = exclude.split(',');
232
+ experimentFiltered = experimentFiltered.filter((item, index) => {
233
+ const positionMatch = excludeItems.some((val: string) => {
234
+ const num = parseInt(val, 10);
235
+ return !isNaN(num) && num - 1 === index;
236
+ });
237
+ const nameMatch = item.name && excludeItems.includes(item.name);
238
+ return !(positionMatch || nameMatch);
239
+ });
240
+ }
241
+
242
+ return experimentFiltered;
243
+ }
244
+
245
+ export function canvasCountdown(seconds: number) {
246
+ if (seconds <= 0) {
247
+ return [];
248
+ }
249
+ return Array.from({ length: seconds }, (_, i) => {
250
+ const number = seconds - i;
251
+
252
+ return {
253
+ draw: (ctx: CanvasRenderingContext2D, w: number, h: number) => {
254
+ ctx.save();
255
+
256
+ ctx.fillStyle = 'black';
257
+ ctx.font = `bold ${Math.min(w, h) * 0.02 * 2}px sans-serif`; // Make countdown text larger
258
+ ctx.textAlign = 'center';
259
+ ctx.textBaseline = 'middle';
260
+ ctx.fillText(number.toString(), w / 2, h / 2);
261
+
262
+ ctx.restore();
263
+ },
264
+ displayDuration: 1000,
265
+ ignoreData: true,
266
+ };
267
+ });
268
+ }
@@ -1 +1,2 @@
1
- VITE_PROLIFIC_CODE=""
1
+ VITE_PROLIFIC_CODE=""
2
+ VITE_DISABLE_SETTINGS=FALSE
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
2
  import { useState } from 'react';
3
- import { Experiment, BaseComponentProps, ExperimentConfig } from '@adriansteffan/reactive';
3
+ import { ExperimentRunner, BaseComponentProps, ExperimentConfig } from '@adriansteffan/reactive';
4
4
 
5
5
 
6
6
  const config: ExperimentConfig = { showProgressBar: true };
@@ -108,13 +108,13 @@ const experiment = [
108
108
  },
109
109
  ];
110
110
 
111
- export default function App() {
111
+ export default function Experiment() {
112
112
  return (
113
- <Experiment
113
+ <ExperimentRunner
114
114
  config={config}
115
115
  timeline={experiment}
116
116
  components={{CustomTrial}}
117
117
  questions={{CustomQuestion}}
118
118
  />
119
119
  );
120
- }
120
+ }
@@ -1,14 +1,14 @@
1
1
  import React from "react";
2
2
  import ReactDOM from "react-dom/client";
3
- import App from "./App.tsx";
3
+ import Experiment from "./Experiment";
4
4
  import "@adriansteffan/reactive/style.css";
5
5
  import "./index.css";
6
6
  import { ExperimentProvider } from "@adriansteffan/reactive";
7
7
 
8
8
  ReactDOM.createRoot(document.getElementById("root")!).render(
9
9
  <React.StrictMode>
10
- <ExperimentProvider>
11
- <App />
10
+ <ExperimentProvider disableSettings={import.meta.env.VITE_DISABLE_SETTINGS}>
11
+ <Experiment />
12
12
  </ExperimentProvider>
13
13
  </React.StrictMode>
14
- );
14
+ );
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "compilerOptions": {
3
+ "noImplicitAny": false,
3
4
  "target": "ES5",
4
5
  "useDefineForClassFields": true,
5
6
  "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2015", "ES2016", "ES2017"],