@m2c2kit/assessment-color-shapes 0.8.31 → 0.8.33
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.
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1178 -13
- package/dist/index.js.map +1 -1
- package/dist/index.min.js +1 -1
- package/package.json +9 -9
- package/schemas.json +118 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,1093 @@
|
|
|
1
|
-
import { Game, RandomDraws, Sprite, Scene, M2Error, WebColors, Transition, Shape, Label, Action, Timer, Easings, TransitionDirection } from '@m2c2kit/core';
|
|
1
|
+
import { Game, RandomDraws, Sprite, Scene, M2Error as M2Error$1, WebColors, Transition, Shape, Label, Action, Timer, Easings, TransitionDirection } from '@m2c2kit/core';
|
|
2
2
|
import { LocalePicker, Instructions, CountdownScene, Grid, Button } from '@m2c2kit/addons';
|
|
3
3
|
|
|
4
|
+
class M2Error extends Error {
|
|
5
|
+
constructor(...params) {
|
|
6
|
+
super(...params);
|
|
7
|
+
this.name = "M2Error";
|
|
8
|
+
Object.setPrototypeOf(this, M2Error.prototype);
|
|
9
|
+
if (Error.captureStackTrace) {
|
|
10
|
+
Error.captureStackTrace(this, M2Error);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
class DataCalc {
|
|
15
|
+
/**
|
|
16
|
+
* A class for transformation and calculation of m2c2kit data.
|
|
17
|
+
*
|
|
18
|
+
* @remarks The purpose is to provide a simple and intuitive interface for
|
|
19
|
+
* assessments to score and summarize their own data. It is not meant for
|
|
20
|
+
* data analysis or statistical modeling. The idiomatic approach is based on the
|
|
21
|
+
* dplyr R package.
|
|
22
|
+
*
|
|
23
|
+
* @param data - An array of observations, where each observation is a set of
|
|
24
|
+
* key-value pairs of variable names and values.
|
|
25
|
+
* @param options - Options, such as groups to group the data by
|
|
26
|
+
* @example
|
|
27
|
+
* ```js
|
|
28
|
+
* const dc = new DataCalc(gameData.trials);
|
|
29
|
+
* const mean_response_time_correct_trials = dc
|
|
30
|
+
* .filter((obs) => obs.correct_response_index === obs.user_response_index)
|
|
31
|
+
* .summarize({ mean_rt: mean("response_time_duration_ms") })
|
|
32
|
+
* .pull("mean_rt");
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
constructor(data, options) {
|
|
36
|
+
this._groups = new Array();
|
|
37
|
+
if (!Array.isArray(data)) {
|
|
38
|
+
throw new M2Error(
|
|
39
|
+
"DataCalc constructor expects an array of observations as first argument"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
for (let i = 0; i < data.length; i++) {
|
|
43
|
+
if (data[i] === null || typeof data[i] !== "object" || Array.isArray(data[i])) {
|
|
44
|
+
throw new M2Error(
|
|
45
|
+
`DataCalc constructor expects all elements to be objects (observations). Element at index ${i} is ${typeof data[i]}. Element: ${JSON.stringify(data[i])}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
this._observations = this.deepCopy(data);
|
|
50
|
+
const allVariables = /* @__PURE__ */ new Set();
|
|
51
|
+
for (const observation of data) {
|
|
52
|
+
for (const key of Object.keys(observation)) {
|
|
53
|
+
allVariables.add(key);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const observation of this._observations) {
|
|
57
|
+
for (const variable of allVariables) {
|
|
58
|
+
if (!(variable in observation)) {
|
|
59
|
+
observation[variable] = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (options?.groups) {
|
|
64
|
+
this._groups = Array.from(options.groups);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Returns the groups in the data.
|
|
69
|
+
*/
|
|
70
|
+
get groups() {
|
|
71
|
+
return this._groups;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Returns the observations in the data.
|
|
75
|
+
*
|
|
76
|
+
* @remarks An observation is conceptually similar to a row in a dataset,
|
|
77
|
+
* where the keys are the variable names and the values are the variable values.
|
|
78
|
+
*/
|
|
79
|
+
get observations() {
|
|
80
|
+
return this._observations;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Alias for the observations property.
|
|
84
|
+
*/
|
|
85
|
+
get rows() {
|
|
86
|
+
return this._observations;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Returns a single variable from the data.
|
|
90
|
+
*
|
|
91
|
+
* @remarks If the variable length is 1, the value is returned. If the
|
|
92
|
+
* variable has length > 1, an array of values is returned. If an empty
|
|
93
|
+
* dataset is provided, `null` is returned and a warning is logged.
|
|
94
|
+
*
|
|
95
|
+
* @param variable - Name of variable to pull from the data
|
|
96
|
+
* @returns the value of the variable
|
|
97
|
+
*
|
|
98
|
+
* @example
|
|
99
|
+
* ```js
|
|
100
|
+
* const d = [{ a: 1, b: 2, c: 3 }];
|
|
101
|
+
* const dc = new DataCalc(d);
|
|
102
|
+
* console.log(
|
|
103
|
+
* dc.pull("c")
|
|
104
|
+
* ); // 3
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
pull(variable) {
|
|
108
|
+
if (this._observations.length === 0) {
|
|
109
|
+
console.warn(
|
|
110
|
+
`DataCalc.pull(): No observations available to pull variable "${variable}" from. Returning null.`
|
|
111
|
+
);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
this.verifyObservationsContainVariable(variable);
|
|
115
|
+
const values = this._observations.map((o) => o[variable]);
|
|
116
|
+
if (values.length === 1) {
|
|
117
|
+
return values[0];
|
|
118
|
+
}
|
|
119
|
+
return values;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Returns the number of observations in the data.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```js
|
|
126
|
+
* const d = [
|
|
127
|
+
* { a: 1, b: 2, c: 3 },
|
|
128
|
+
* { a: 0, b: 8, c: 3 }
|
|
129
|
+
* ];
|
|
130
|
+
* const dc = new DataCalc(d);
|
|
131
|
+
* console.log(
|
|
132
|
+
* dc.length
|
|
133
|
+
* ); // 2
|
|
134
|
+
* ```
|
|
135
|
+
*/
|
|
136
|
+
get length() {
|
|
137
|
+
return this._observations.length;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Filters observations based on a predicate function.
|
|
141
|
+
*
|
|
142
|
+
* @param predicate - A function that returns true for observations to keep and
|
|
143
|
+
* false for observations to discard
|
|
144
|
+
* @returns A new `DataCalc` object with only the observations that pass the
|
|
145
|
+
* predicate function
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```js
|
|
149
|
+
* const d = [
|
|
150
|
+
* { a: 1, b: 2, c: 3 },
|
|
151
|
+
* { a: 0, b: 8, c: 3 },
|
|
152
|
+
* { a: 9, b: 4, c: 7 },
|
|
153
|
+
* ];
|
|
154
|
+
* const dc = new DataCalc(d);
|
|
155
|
+
* console.log(dc.filter((obs) => obs.b >= 3).observations);
|
|
156
|
+
* // [ { a: 0, b: 8, c: 3 }, { a: 9, b: 4, c: 7 } ]
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
filter(predicate) {
|
|
160
|
+
if (this._groups.length > 0) {
|
|
161
|
+
throw new M2Error(
|
|
162
|
+
`filter() cannot be used on grouped data. The data are currently grouped by ${this._groups.join(
|
|
163
|
+
", "
|
|
164
|
+
)}. Ungroup the data first using ungroup().`
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return new DataCalc(
|
|
168
|
+
this._observations.filter(
|
|
169
|
+
predicate
|
|
170
|
+
),
|
|
171
|
+
{ groups: this._groups }
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Groups observations by one or more variables.
|
|
176
|
+
*
|
|
177
|
+
* @remarks This is used with the `summarize()` method to calculate summaries
|
|
178
|
+
* by group.
|
|
179
|
+
*
|
|
180
|
+
* @param groups - variable names to group by
|
|
181
|
+
* @returns A new `DataCalc` object with the observations grouped by one or
|
|
182
|
+
* more variables
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* ```js
|
|
186
|
+
* const d = [
|
|
187
|
+
* { a: 1, b: 2, c: 3 },
|
|
188
|
+
* { a: 0, b: 8, c: 3 },
|
|
189
|
+
* { a: 9, b: 4, c: 7 },
|
|
190
|
+
* { a: 5, b: 0, c: 7 },
|
|
191
|
+
* ];
|
|
192
|
+
* const dc = new DataCalc(d);
|
|
193
|
+
* const grouped = dc.groupBy("c");
|
|
194
|
+
* // subsequent summarize operations will be performed separately by
|
|
195
|
+
* // each unique level of c, in this case, 3 and 7
|
|
196
|
+
* ```
|
|
197
|
+
*/
|
|
198
|
+
groupBy(...groups) {
|
|
199
|
+
groups.forEach((group) => {
|
|
200
|
+
this.verifyObservationsContainVariable(group);
|
|
201
|
+
});
|
|
202
|
+
return new DataCalc(this._observations, { groups });
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Ungroups observations.
|
|
206
|
+
*
|
|
207
|
+
* @returns A new DataCalc object with the observations ungrouped
|
|
208
|
+
*/
|
|
209
|
+
ungroup() {
|
|
210
|
+
return new DataCalc(this._observations);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Adds new variables to the observations based on the provided mutation options.
|
|
214
|
+
*
|
|
215
|
+
* @param mutations - An object where the keys are the names of the new variables
|
|
216
|
+
* and the values are functions that take an observation and return the value
|
|
217
|
+
* for the new variable.
|
|
218
|
+
* @returns A new DataCalc object with the new variables added to the observations.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* const d = [
|
|
222
|
+
* { a: 1, b: 2, c: 3 },
|
|
223
|
+
* { a: 0, b: 8, c: 3 },
|
|
224
|
+
* { a: 9, b: 4, c: 7 },
|
|
225
|
+
* ];
|
|
226
|
+
* const dc = new DataCalc(d);
|
|
227
|
+
* console.log(
|
|
228
|
+
* dc.mutate({ doubledA: (obs) => obs.a * 2 }).observations
|
|
229
|
+
* );
|
|
230
|
+
* // [ { a: 1, b: 2, c: 3, doubledA: 2 },
|
|
231
|
+
* // { a: 0, b: 8, c: 3, doubledA: 0 },
|
|
232
|
+
* // { a: 9, b: 4, c: 7, doubledA: 18 } ]
|
|
233
|
+
*/
|
|
234
|
+
mutate(mutations) {
|
|
235
|
+
if (this._groups.length > 0) {
|
|
236
|
+
throw new M2Error(
|
|
237
|
+
`mutate() cannot be used on grouped data. The data are currently grouped by ${this._groups.join(
|
|
238
|
+
", "
|
|
239
|
+
)}. Ungroup the data first using ungroup().`
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
const newObservations = this._observations.map((observation) => {
|
|
243
|
+
let newObservation = { ...observation };
|
|
244
|
+
for (const [newVariable, transformFunction] of Object.entries(
|
|
245
|
+
mutations
|
|
246
|
+
)) {
|
|
247
|
+
newObservation = {
|
|
248
|
+
...newObservation,
|
|
249
|
+
[newVariable]: transformFunction(observation)
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return newObservation;
|
|
253
|
+
});
|
|
254
|
+
return new DataCalc(newObservations, { groups: this._groups });
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Calculates summaries of the data.
|
|
258
|
+
*
|
|
259
|
+
* @param summarizations - An object where the keys are the names of the new
|
|
260
|
+
* variables and the values are `DataCalc` summary functions: `sum()`,
|
|
261
|
+
* `mean()`, `median()`, `variance()`, `sd()`, `min()`, `max()`, or `n()`.
|
|
262
|
+
* The summary functions take a variable name as a string, or alternatively,
|
|
263
|
+
* a value or array of values to summarize.
|
|
264
|
+
* @returns A new `DataCalc` object with the new summary variables.
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```js
|
|
268
|
+
* const d = [
|
|
269
|
+
* { a: 1, b: 2, c: 3 },
|
|
270
|
+
* { a: 0, b: 8, c: 3 },
|
|
271
|
+
* { a: 9, b: 4, c: 7 },
|
|
272
|
+
* { a: 5, b: 0, c: 7 },
|
|
273
|
+
* ];
|
|
274
|
+
* const dc = new DataCalc(d);
|
|
275
|
+
* console.log(
|
|
276
|
+
* dc.summarize({
|
|
277
|
+
* meanA: mean("a"),
|
|
278
|
+
* varA: variance("a"),
|
|
279
|
+
* totalB: sum("b")
|
|
280
|
+
* }).observations
|
|
281
|
+
* );
|
|
282
|
+
* // [ { meanA: 3.75, varA: 16.916666666666668, totalB: 14 } ]
|
|
283
|
+
*
|
|
284
|
+
* console.log(
|
|
285
|
+
* dc.summarize({
|
|
286
|
+
* filteredTotalC: sum(dc.filter(obs => obs.b > 2).pull("c"))
|
|
287
|
+
* }).observations
|
|
288
|
+
* );
|
|
289
|
+
* // [ { filteredTotalC: 10 } ]
|
|
290
|
+
* ```
|
|
291
|
+
*/
|
|
292
|
+
summarize(summarizations) {
|
|
293
|
+
if (this._groups.length === 0) {
|
|
294
|
+
const obs = {};
|
|
295
|
+
for (const [newVariable, value] of Object.entries(summarizations)) {
|
|
296
|
+
if (typeof value === "object" && value !== null && "summarizeFunction" in value) {
|
|
297
|
+
const summarizeOperation = value;
|
|
298
|
+
obs[newVariable] = summarizeOperation.summarizeFunction(
|
|
299
|
+
this,
|
|
300
|
+
summarizeOperation.parameters,
|
|
301
|
+
summarizeOperation.options
|
|
302
|
+
);
|
|
303
|
+
} else {
|
|
304
|
+
obs[newVariable] = value;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return new DataCalc([obs], { groups: this._groups });
|
|
308
|
+
}
|
|
309
|
+
return this.summarizeByGroups(summarizations);
|
|
310
|
+
}
|
|
311
|
+
summarizeByGroups(summarizations) {
|
|
312
|
+
const groupMap = /* @__PURE__ */ new Map();
|
|
313
|
+
this._observations.forEach((obs) => {
|
|
314
|
+
const groupKey = this._groups.map(
|
|
315
|
+
(g) => typeof obs[g] === "object" ? JSON.stringify(obs[g]) : obs[g]
|
|
316
|
+
).join("|");
|
|
317
|
+
if (!groupMap.has(groupKey)) {
|
|
318
|
+
groupMap.set(groupKey, []);
|
|
319
|
+
}
|
|
320
|
+
const groupArray = groupMap.get(groupKey);
|
|
321
|
+
if (groupArray) {
|
|
322
|
+
groupArray.push(obs);
|
|
323
|
+
} else {
|
|
324
|
+
groupMap.set(groupKey, [obs]);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
const summarizedObservations = [];
|
|
328
|
+
groupMap.forEach((groupObs, groupKey) => {
|
|
329
|
+
const groupValues = groupKey.split("|");
|
|
330
|
+
const firstObs = groupObs[0];
|
|
331
|
+
const summaryObj = {};
|
|
332
|
+
this._groups.forEach((group, i) => {
|
|
333
|
+
const valueStr = groupValues[i];
|
|
334
|
+
const originalType = typeof firstObs[group];
|
|
335
|
+
if (originalType === "number") {
|
|
336
|
+
summaryObj[group] = Number(valueStr);
|
|
337
|
+
} else if (originalType === "boolean") {
|
|
338
|
+
summaryObj[group] = valueStr === "true";
|
|
339
|
+
} else if (valueStr.startsWith("{") || valueStr.startsWith("[")) {
|
|
340
|
+
try {
|
|
341
|
+
summaryObj[group] = JSON.parse(valueStr);
|
|
342
|
+
} catch {
|
|
343
|
+
throw new M2Error(
|
|
344
|
+
`Failed to parse group value ${valueStr} as JSON for group ${group}`
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
} else {
|
|
348
|
+
summaryObj[group] = valueStr;
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
const groupDataCalc = new DataCalc(groupObs);
|
|
352
|
+
for (const [newVariable, value] of Object.entries(summarizations)) {
|
|
353
|
+
if (typeof value === "object" && value !== null && "summarizeFunction" in value) {
|
|
354
|
+
const summarizeOperation = value;
|
|
355
|
+
summaryObj[newVariable] = summarizeOperation.summarizeFunction(
|
|
356
|
+
groupDataCalc,
|
|
357
|
+
summarizeOperation.parameters,
|
|
358
|
+
summarizeOperation.options
|
|
359
|
+
);
|
|
360
|
+
} else {
|
|
361
|
+
summaryObj[newVariable] = value;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
summarizedObservations.push(summaryObj);
|
|
365
|
+
});
|
|
366
|
+
return new DataCalc(summarizedObservations, { groups: this._groups });
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Selects specific variables to keep in the dataset.
|
|
370
|
+
* Variables prefixed with "-" will be excluded from the result.
|
|
371
|
+
*
|
|
372
|
+
* @param variables - Names of variables to select; prefix with '-' to exclude instead
|
|
373
|
+
* @returns A new DataCalc object with only the selected variables (minus excluded ones)
|
|
374
|
+
*
|
|
375
|
+
* @example
|
|
376
|
+
* ```js
|
|
377
|
+
* const d = [
|
|
378
|
+
* { a: 1, b: 2, c: 3, d: 4 },
|
|
379
|
+
* { a: 5, b: 6, c: 7, d: 8 }
|
|
380
|
+
* ];
|
|
381
|
+
* const dc = new DataCalc(d);
|
|
382
|
+
* // Keep a and c
|
|
383
|
+
* console.log(dc.select("a", "c").observations);
|
|
384
|
+
* // [ { a: 1, c: 3 }, { a: 5, c: 7 } ]
|
|
385
|
+
* ```
|
|
386
|
+
*/
|
|
387
|
+
select(...variables) {
|
|
388
|
+
const includeVars = [];
|
|
389
|
+
const excludeVars = [];
|
|
390
|
+
variables.forEach((variable) => {
|
|
391
|
+
if (variable.startsWith("-")) {
|
|
392
|
+
excludeVars.push(variable.substring(1));
|
|
393
|
+
} else {
|
|
394
|
+
includeVars.push(variable);
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
const allVars = includeVars.length > 0 ? includeVars : Object.keys(this._observations[0] || {});
|
|
398
|
+
[...allVars, ...excludeVars].forEach((variable) => {
|
|
399
|
+
this.verifyObservationsContainVariable(variable);
|
|
400
|
+
});
|
|
401
|
+
const excludeSet = new Set(excludeVars);
|
|
402
|
+
const newObservations = this._observations.map((observation) => {
|
|
403
|
+
const newObservation = {};
|
|
404
|
+
if (includeVars.length > 0) {
|
|
405
|
+
includeVars.forEach((variable) => {
|
|
406
|
+
if (!excludeSet.has(variable)) {
|
|
407
|
+
newObservation[variable] = observation[variable];
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
Object.keys(observation).forEach((key) => {
|
|
412
|
+
if (!excludeSet.has(key)) {
|
|
413
|
+
newObservation[key] = observation[key];
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return newObservation;
|
|
418
|
+
});
|
|
419
|
+
return new DataCalc(newObservations, { groups: this._groups });
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Arranges (sorts) the observations based on one or more variables.
|
|
423
|
+
*
|
|
424
|
+
* @param variables - Names of variables to sort by, prefixed with '-' for descending order
|
|
425
|
+
* @returns A new DataCalc object with the observations sorted
|
|
426
|
+
*
|
|
427
|
+
* @example
|
|
428
|
+
* ```js
|
|
429
|
+
* const d = [
|
|
430
|
+
* { a: 5, b: 2 },
|
|
431
|
+
* { a: 3, b: 7 },
|
|
432
|
+
* { a: 5, b: 1 }
|
|
433
|
+
* ];
|
|
434
|
+
* const dc = new DataCalc(d);
|
|
435
|
+
* // Sort by a (ascending), then by b (descending)
|
|
436
|
+
* console.log(dc.arrange("a", "-b").observations);
|
|
437
|
+
* // [ { a: 3, b: 7 }, { a: 5, b: 2 }, { a: 5, b: 1 } ]
|
|
438
|
+
* ```
|
|
439
|
+
*/
|
|
440
|
+
arrange(...variables) {
|
|
441
|
+
if (this._groups.length > 0) {
|
|
442
|
+
throw new M2Error(
|
|
443
|
+
`arrange() cannot be used on grouped data. The data are currently grouped by ${this._groups.join(
|
|
444
|
+
", "
|
|
445
|
+
)}. Ungroup the data first using ungroup().`
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const sortedObservations = [...this._observations].sort((a, b) => {
|
|
449
|
+
for (const variable of variables) {
|
|
450
|
+
let varName = variable;
|
|
451
|
+
let direction = 1;
|
|
452
|
+
if (variable.startsWith("-")) {
|
|
453
|
+
varName = variable.substring(1);
|
|
454
|
+
direction = -1;
|
|
455
|
+
}
|
|
456
|
+
if (!(varName in a) || !(varName in b)) {
|
|
457
|
+
throw new M2Error(
|
|
458
|
+
`arrange(): variable ${varName} does not exist in all observations`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const aVal = a[varName];
|
|
462
|
+
const bVal = b[varName];
|
|
463
|
+
if (typeof aVal !== typeof bVal) {
|
|
464
|
+
return direction * (String(aVal) < String(bVal) ? -1 : 1);
|
|
465
|
+
}
|
|
466
|
+
if (aVal < bVal) return -1 * direction;
|
|
467
|
+
if (aVal > bVal) return 1 * direction;
|
|
468
|
+
}
|
|
469
|
+
return 0;
|
|
470
|
+
});
|
|
471
|
+
return new DataCalc(sortedObservations, { groups: this._groups });
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Keeps only unique/distinct observations.
|
|
475
|
+
*
|
|
476
|
+
* @returns A new `DataCalc` object with only unique observations
|
|
477
|
+
*
|
|
478
|
+
* @example
|
|
479
|
+
* ```js
|
|
480
|
+
* const d = [
|
|
481
|
+
* { a: 1, b: 2, c: 3 },
|
|
482
|
+
* { a: 1, b: 2, c: 3 }, // Duplicate
|
|
483
|
+
* { a: 2, b: 3, c: 5 },
|
|
484
|
+
* { a: 1, b: 2, c: { name: "dog" } },
|
|
485
|
+
* { a: 1, b: 2, c: { name: "dog" } } // Duplicate with nested object
|
|
486
|
+
* ];
|
|
487
|
+
* const dc = new DataCalc(d);
|
|
488
|
+
* console.log(dc.distinct().observations);
|
|
489
|
+
* // [ { a: 1, b: 2, c: 3 }, { a: 2, b: 3, c: 5 }, { a: 1, b: 2, c: { name: "dog" } } ]
|
|
490
|
+
* ```
|
|
491
|
+
*/
|
|
492
|
+
distinct() {
|
|
493
|
+
const seen = /* @__PURE__ */ new Set();
|
|
494
|
+
const uniqueObs = this._observations.filter((obs) => {
|
|
495
|
+
const key = JSON.stringify(this.normalizeForComparison(obs));
|
|
496
|
+
if (seen.has(key)) return false;
|
|
497
|
+
seen.add(key);
|
|
498
|
+
return true;
|
|
499
|
+
});
|
|
500
|
+
return new DataCalc(uniqueObs, { groups: this._groups });
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Renames variables in the observations.
|
|
504
|
+
*
|
|
505
|
+
* @param renames - Object mapping new variable names to old variable names
|
|
506
|
+
* @returns A new DataCalc object with renamed variables
|
|
507
|
+
*
|
|
508
|
+
* @example
|
|
509
|
+
* ```js
|
|
510
|
+
* const d = [
|
|
511
|
+
* { a: 1, b: 2, c: 3 },
|
|
512
|
+
* { a: 4, b: 5, c: 6 }
|
|
513
|
+
* ];
|
|
514
|
+
* const dc = new DataCalc(d);
|
|
515
|
+
* console.log(dc.rename({ x: 'a', z: 'c' }).observations);
|
|
516
|
+
* // [ { x: 1, b: 2, z: 3 }, { x: 4, b: 5, z: 6 } ]
|
|
517
|
+
* ```
|
|
518
|
+
*/
|
|
519
|
+
rename(renames) {
|
|
520
|
+
if (this._observations.length === 0) {
|
|
521
|
+
throw new M2Error("Cannot rename variables on an empty dataset");
|
|
522
|
+
}
|
|
523
|
+
Object.values(renames).forEach((oldName) => {
|
|
524
|
+
this.verifyObservationsContainVariable(oldName);
|
|
525
|
+
});
|
|
526
|
+
const newObservations = this._observations.map((observation) => {
|
|
527
|
+
const newObservation = {};
|
|
528
|
+
for (const [key, value] of Object.entries(observation)) {
|
|
529
|
+
const newKey = Object.entries(renames).find(
|
|
530
|
+
([, old]) => old === key
|
|
531
|
+
)?.[0];
|
|
532
|
+
if (newKey) {
|
|
533
|
+
newObservation[newKey] = value;
|
|
534
|
+
} else if (!Object.values(renames).includes(key)) {
|
|
535
|
+
newObservation[key] = value;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return newObservation;
|
|
539
|
+
});
|
|
540
|
+
return new DataCalc(newObservations, { groups: this._groups });
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Performs an inner join with another DataCalc object.
|
|
544
|
+
* Only rows with matching keys in both datasets are included.
|
|
545
|
+
*
|
|
546
|
+
* @param other - The other DataCalc object to join with
|
|
547
|
+
* @param by - The variables to join on
|
|
548
|
+
* @returns A new DataCalc object with joined observations
|
|
549
|
+
*
|
|
550
|
+
* @example
|
|
551
|
+
* ```js
|
|
552
|
+
* const d1 = [
|
|
553
|
+
* { id: 1, x: 'a' },
|
|
554
|
+
* { id: 2, x: 'b' },
|
|
555
|
+
* { id: 3, x: 'c' }
|
|
556
|
+
* ];
|
|
557
|
+
* const d2 = [
|
|
558
|
+
* { id: 1, y: 100 },
|
|
559
|
+
* { id: 2, y: 200 },
|
|
560
|
+
* { id: 4, y: 400 }
|
|
561
|
+
* ];
|
|
562
|
+
* const dc1 = new DataCalc(d1);
|
|
563
|
+
* const dc2 = new DataCalc(d2);
|
|
564
|
+
* console.log(dc1.innerJoin(dc2, ["id"]).observations);
|
|
565
|
+
* // [ { id: 1, x: 'a', y: 100 }, { id: 2, x: 'b', y: 200 } ]
|
|
566
|
+
* ```
|
|
567
|
+
*/
|
|
568
|
+
innerJoin(other, by) {
|
|
569
|
+
if (this._groups.length > 0 || other._groups.length > 0) {
|
|
570
|
+
throw new M2Error(
|
|
571
|
+
`innerJoin() cannot be used on grouped data. Ungroup the data first using ungroup().`
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
by.forEach((key) => {
|
|
575
|
+
this.verifyObservationsContainVariable(key);
|
|
576
|
+
other.verifyObservationsContainVariable(key);
|
|
577
|
+
});
|
|
578
|
+
const rightMap = /* @__PURE__ */ new Map();
|
|
579
|
+
other.observations.forEach((obs) => {
|
|
580
|
+
if (this.hasNullJoinKeys(obs, by)) {
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(obs[k]))).join("|");
|
|
584
|
+
const matches = rightMap.get(key) || [];
|
|
585
|
+
matches.push(obs);
|
|
586
|
+
rightMap.set(key, matches);
|
|
587
|
+
});
|
|
588
|
+
const result = [];
|
|
589
|
+
this._observations.forEach((leftObs) => {
|
|
590
|
+
if (this.hasNullJoinKeys(leftObs, by)) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(leftObs[k]))).join("|");
|
|
594
|
+
const rightMatches = rightMap.get(key) || [];
|
|
595
|
+
if (rightMatches.length > 0) {
|
|
596
|
+
rightMatches.forEach((rightObs) => {
|
|
597
|
+
const joinedObs = { ...leftObs };
|
|
598
|
+
Object.entries(rightObs).forEach(([k, v]) => {
|
|
599
|
+
if (!by.includes(k)) {
|
|
600
|
+
joinedObs[k] = v;
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
result.push(joinedObs);
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
return new DataCalc(result);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Performs a left join with another DataCalc object.
|
|
611
|
+
* All rows from the left dataset are included, along with matching rows from the right.
|
|
612
|
+
*
|
|
613
|
+
* @param other - The other DataCalc object to join with
|
|
614
|
+
* @param by - The variables to join on
|
|
615
|
+
* @returns A new DataCalc object with joined observations
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* ```js
|
|
619
|
+
* const d1 = [
|
|
620
|
+
* { id: 1, x: 'a' },
|
|
621
|
+
* { id: 2, x: 'b' },
|
|
622
|
+
* { id: 3, x: 'c' }
|
|
623
|
+
* ];
|
|
624
|
+
* const d2 = [
|
|
625
|
+
* { id: 1, y: 100 },
|
|
626
|
+
* { id: 2, y: 200 }
|
|
627
|
+
* ];
|
|
628
|
+
* const dc1 = new DataCalc(d1);
|
|
629
|
+
* const dc2 = new DataCalc(d2);
|
|
630
|
+
* console.log(dc1.leftJoin(dc2, ["id"]).observations);
|
|
631
|
+
* // [ { id: 1, x: 'a', y: 100 }, { id: 2, x: 'b', y: 200 }, { id: 3, x: 'c' } ]
|
|
632
|
+
* ```
|
|
633
|
+
*/
|
|
634
|
+
leftJoin(other, by) {
|
|
635
|
+
if (this._groups.length > 0 || other._groups.length > 0) {
|
|
636
|
+
throw new M2Error(
|
|
637
|
+
`leftJoin() cannot be used on grouped data. Ungroup the data first using ungroup().`
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
by.forEach((key) => {
|
|
641
|
+
this.verifyObservationsContainVariable(key);
|
|
642
|
+
other.verifyObservationsContainVariable(key);
|
|
643
|
+
});
|
|
644
|
+
const rightMap = /* @__PURE__ */ new Map();
|
|
645
|
+
other.observations.forEach((obs) => {
|
|
646
|
+
if (this.hasNullJoinKeys(obs, by)) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(obs[k]))).join("|");
|
|
650
|
+
const matches = rightMap.get(key) || [];
|
|
651
|
+
matches.push(obs);
|
|
652
|
+
rightMap.set(key, matches);
|
|
653
|
+
});
|
|
654
|
+
const result = [];
|
|
655
|
+
this._observations.forEach((leftObs) => {
|
|
656
|
+
if (this.hasNullJoinKeys(leftObs, by)) {
|
|
657
|
+
result.push({ ...leftObs });
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(leftObs[k]))).join("|");
|
|
661
|
+
const rightMatches = rightMap.get(key) || [];
|
|
662
|
+
if (rightMatches.length > 0) {
|
|
663
|
+
rightMatches.forEach((rightObs) => {
|
|
664
|
+
const joinedObs = { ...leftObs };
|
|
665
|
+
Object.entries(rightObs).forEach(([k, v]) => {
|
|
666
|
+
if (!by.includes(k)) {
|
|
667
|
+
joinedObs[k] = v;
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
result.push(joinedObs);
|
|
671
|
+
});
|
|
672
|
+
} else {
|
|
673
|
+
result.push({ ...leftObs });
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
return new DataCalc(result);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Performs a right join with another DataCalc object.
|
|
680
|
+
* All rows from the right dataset are included, along with matching rows from the left.
|
|
681
|
+
*
|
|
682
|
+
* @param other - The other DataCalc object to join with
|
|
683
|
+
* @param by - The variables to join on
|
|
684
|
+
* @returns A new DataCalc object with joined observations
|
|
685
|
+
*
|
|
686
|
+
* @example
|
|
687
|
+
* ```js
|
|
688
|
+
* const d1 = [
|
|
689
|
+
* { id: 1, x: 'a' },
|
|
690
|
+
* { id: 2, x: 'b' }
|
|
691
|
+
* ];
|
|
692
|
+
* const d2 = [
|
|
693
|
+
* { id: 1, y: 100 },
|
|
694
|
+
* { id: 2, y: 200 },
|
|
695
|
+
* { id: 4, y: 400 }
|
|
696
|
+
* ];
|
|
697
|
+
* const dc1 = new DataCalc(d1);
|
|
698
|
+
* const dc2 = new DataCalc(d2);
|
|
699
|
+
* console.log(dc1.rightJoin(dc2, ["id"]).observations);
|
|
700
|
+
* // [ { id: 1, x: 'a', y: 100 }, { id: 2, x: 'b', y: 200 }, { id: 4, y: 400 } ]
|
|
701
|
+
* ```
|
|
702
|
+
*/
|
|
703
|
+
rightJoin(other, by) {
|
|
704
|
+
if (this._groups.length > 0 || other._groups.length > 0) {
|
|
705
|
+
throw new M2Error(
|
|
706
|
+
`rightJoin() cannot be used on grouped data. Ungroup the data first using ungroup().`
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
by.forEach((key) => {
|
|
710
|
+
this.verifyObservationsContainVariable(key);
|
|
711
|
+
other.verifyObservationsContainVariable(key);
|
|
712
|
+
});
|
|
713
|
+
const rightMap = /* @__PURE__ */ new Map();
|
|
714
|
+
other.observations.forEach((obs) => {
|
|
715
|
+
if (this.hasNullJoinKeys(obs, by)) {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(obs[k]))).join("|");
|
|
719
|
+
const matches = rightMap.get(key) || [];
|
|
720
|
+
matches.push(obs);
|
|
721
|
+
rightMap.set(key, matches);
|
|
722
|
+
});
|
|
723
|
+
const result = [];
|
|
724
|
+
const processedRightKeys = /* @__PURE__ */ new Set();
|
|
725
|
+
this._observations.forEach((leftObs) => {
|
|
726
|
+
if (this.hasNullJoinKeys(leftObs, by)) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(leftObs[k]))).join("|");
|
|
730
|
+
const rightMatches = rightMap.get(key) || [];
|
|
731
|
+
if (rightMatches.length > 0) {
|
|
732
|
+
rightMatches.forEach((rightObs) => {
|
|
733
|
+
const joinedObs = { ...leftObs };
|
|
734
|
+
Object.entries(rightObs).forEach(([k, v]) => {
|
|
735
|
+
if (!by.includes(k)) {
|
|
736
|
+
joinedObs[k] = v;
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
result.push(joinedObs);
|
|
740
|
+
});
|
|
741
|
+
processedRightKeys.add(key);
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
other.observations.forEach((rightObs) => {
|
|
745
|
+
if (this.hasNullJoinKeys(rightObs, by)) {
|
|
746
|
+
result.push({ ...rightObs });
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(rightObs[k]))).join("|");
|
|
750
|
+
if (!processedRightKeys.has(key)) {
|
|
751
|
+
result.push({ ...rightObs });
|
|
752
|
+
processedRightKeys.add(key);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
return new DataCalc(result);
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Performs a full join with another DataCalc object.
|
|
759
|
+
* All rows from both datasets are included.
|
|
760
|
+
*
|
|
761
|
+
* @param other - The other DataCalc object to join with
|
|
762
|
+
* @param by - The variables to join on
|
|
763
|
+
* @returns A new DataCalc object with joined observations
|
|
764
|
+
*
|
|
765
|
+
* @example
|
|
766
|
+
* ```js
|
|
767
|
+
* const d1 = [
|
|
768
|
+
* { id: 1, x: 'a' },
|
|
769
|
+
* { id: 2, x: 'b' },
|
|
770
|
+
* { id: 3, x: 'c' }
|
|
771
|
+
* ];
|
|
772
|
+
* const d2 = [
|
|
773
|
+
* { id: 1, y: 100 },
|
|
774
|
+
* { id: 2, y: 200 },
|
|
775
|
+
* { id: 4, y: 400 }
|
|
776
|
+
* ];
|
|
777
|
+
* const dc1 = new DataCalc(d1);
|
|
778
|
+
* const dc2 = new DataCalc(d2);
|
|
779
|
+
* console.log(dc1.fullJoin(dc2, ["id"]).observations);
|
|
780
|
+
* // [
|
|
781
|
+
* // { id: 1, x: 'a', y: 100 },
|
|
782
|
+
* // { id: 2, x: 'b', y: 200 },
|
|
783
|
+
* // { id: 3, x: 'c' },
|
|
784
|
+
* // { id: 4, y: 400 }
|
|
785
|
+
* // ]
|
|
786
|
+
* ```
|
|
787
|
+
*/
|
|
788
|
+
fullJoin(other, by) {
|
|
789
|
+
if (this._groups.length > 0 || other._groups.length > 0) {
|
|
790
|
+
throw new M2Error(
|
|
791
|
+
`fullJoin() cannot be used on grouped data. Ungroup the data first using ungroup().`
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
by.forEach((key) => {
|
|
795
|
+
this.verifyObservationsContainVariable(key);
|
|
796
|
+
other.verifyObservationsContainVariable(key);
|
|
797
|
+
});
|
|
798
|
+
const rightMap = /* @__PURE__ */ new Map();
|
|
799
|
+
other.observations.forEach((obs) => {
|
|
800
|
+
if (this.hasNullJoinKeys(obs, by)) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(obs[k]))).join("|");
|
|
804
|
+
const matches = rightMap.get(key) || [];
|
|
805
|
+
matches.push(obs);
|
|
806
|
+
rightMap.set(key, matches);
|
|
807
|
+
});
|
|
808
|
+
const result = [];
|
|
809
|
+
const processedRightKeys = /* @__PURE__ */ new Set();
|
|
810
|
+
this._observations.forEach((leftObs) => {
|
|
811
|
+
if (this.hasNullJoinKeys(leftObs, by)) {
|
|
812
|
+
result.push({ ...leftObs });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(leftObs[k]))).join("|");
|
|
816
|
+
const rightMatches = rightMap.get(key) || [];
|
|
817
|
+
if (rightMatches.length > 0) {
|
|
818
|
+
rightMatches.forEach((rightObs) => {
|
|
819
|
+
const joinedObs = { ...leftObs };
|
|
820
|
+
Object.entries(rightObs).forEach(([k, v]) => {
|
|
821
|
+
if (!by.includes(k)) {
|
|
822
|
+
joinedObs[k] = v;
|
|
823
|
+
}
|
|
824
|
+
});
|
|
825
|
+
result.push(joinedObs);
|
|
826
|
+
});
|
|
827
|
+
processedRightKeys.add(key);
|
|
828
|
+
} else {
|
|
829
|
+
result.push({ ...leftObs });
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
other.observations.forEach((rightObs) => {
|
|
833
|
+
if (this.hasNullJoinKeys(rightObs, by)) {
|
|
834
|
+
result.push({ ...rightObs });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
const key = by.map((k) => JSON.stringify(this.normalizeForComparison(rightObs[k]))).join("|");
|
|
838
|
+
if (!processedRightKeys.has(key)) {
|
|
839
|
+
result.push({ ...rightObs });
|
|
840
|
+
processedRightKeys.add(key);
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
return new DataCalc(result);
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Slice observations by position.
|
|
847
|
+
*
|
|
848
|
+
* @param start - Starting position (0-based). Negative values count from
|
|
849
|
+
* the end.
|
|
850
|
+
* @param end - Ending position (exclusive)
|
|
851
|
+
* @returns A new DataCalc object with sliced observations
|
|
852
|
+
*
|
|
853
|
+
* @remarks If `end` is not provided, it will return a single observation at
|
|
854
|
+
* `start` position. If `start` is beyond the length of observations,
|
|
855
|
+
* it will return an empty DataCalc.
|
|
856
|
+
*
|
|
857
|
+
* @example
|
|
858
|
+
* ```js
|
|
859
|
+
* const d = [
|
|
860
|
+
* { a: 1, b: 2 },
|
|
861
|
+
* { a: 3, b: 4 },
|
|
862
|
+
* { a: 5, b: 6 },
|
|
863
|
+
* { a: 7, b: 8 }
|
|
864
|
+
* ];
|
|
865
|
+
* const dc = new DataCalc(d);
|
|
866
|
+
* console.log(dc.slice(1, 3).observations);
|
|
867
|
+
* // [ { a: 3, b: 4 }, { a: 5, b: 6 } ]
|
|
868
|
+
* console.log(dc.slice(0).observations);
|
|
869
|
+
* // [ { a: 1, b: 2 } ]
|
|
870
|
+
* ```
|
|
871
|
+
*/
|
|
872
|
+
slice(start, end) {
|
|
873
|
+
if (this._groups.length > 0) {
|
|
874
|
+
throw new M2Error(
|
|
875
|
+
`slice() cannot be used on grouped data. Ungroup the data first using ungroup().`
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
let sliced;
|
|
879
|
+
if (start >= this._observations.length) {
|
|
880
|
+
return new DataCalc([], { groups: this._groups });
|
|
881
|
+
}
|
|
882
|
+
if (end === void 0) {
|
|
883
|
+
const index = start < 0 ? this._observations.length + start : start;
|
|
884
|
+
sliced = [this._observations[index]];
|
|
885
|
+
} else {
|
|
886
|
+
sliced = this._observations.slice(start, end);
|
|
887
|
+
}
|
|
888
|
+
return new DataCalc(sliced, { groups: this._groups });
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Combines observations from two DataCalc objects by rows.
|
|
892
|
+
*
|
|
893
|
+
* @param other - The other DataCalc object to bind with
|
|
894
|
+
* @returns A new DataCalc object with combined observations
|
|
895
|
+
*
|
|
896
|
+
* @example
|
|
897
|
+
* ```js
|
|
898
|
+
* const d1 = [
|
|
899
|
+
* { a: 1, b: 2 },
|
|
900
|
+
* { a: 3, b: 4 }
|
|
901
|
+
* ];
|
|
902
|
+
* const d2 = [
|
|
903
|
+
* { a: 5, b: 6 },
|
|
904
|
+
* { a: 7, b: 8 }
|
|
905
|
+
* ];
|
|
906
|
+
* const dc1 = new DataCalc(d1);
|
|
907
|
+
* const dc2 = new DataCalc(d2);
|
|
908
|
+
* console.log(dc1.bindRows(dc2).observations);
|
|
909
|
+
* // [ { a: 1, b: 2 }, { a: 3, b: 4 }, { a: 5, b: 6 }, { a: 7, b: 8 } ]
|
|
910
|
+
* ```
|
|
911
|
+
*/
|
|
912
|
+
bindRows(other) {
|
|
913
|
+
if (this._observations.length > 0 && other.observations.length > 0) {
|
|
914
|
+
const thisVariables = new Set(Object.keys(this._observations[0]));
|
|
915
|
+
const otherVariables = new Set(Object.keys(other.observations[0]));
|
|
916
|
+
const commonVariables = [...thisVariables].filter(
|
|
917
|
+
(variable) => otherVariables.has(variable)
|
|
918
|
+
);
|
|
919
|
+
commonVariables.forEach((variable) => {
|
|
920
|
+
const thisType = this.getVariableType(variable);
|
|
921
|
+
const otherType = other.getVariableType(variable);
|
|
922
|
+
if (thisType !== otherType) {
|
|
923
|
+
console.warn(
|
|
924
|
+
`Warning: bindRows() is combining datasets with different data types for variable '${variable}'. Left dataset has type '${thisType}' and right dataset has type '${otherType}'.`
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return new DataCalc([...this._observations, ...other.observations]);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Helper method to determine the primary type of a variable across observations
|
|
933
|
+
* @internal
|
|
934
|
+
*
|
|
935
|
+
* @param variable - The variable name to check
|
|
936
|
+
* @returns The most common type for the variable or 'mixed' if no clear type exists
|
|
937
|
+
*/
|
|
938
|
+
getVariableType(variable) {
|
|
939
|
+
if (this._observations.length === 0) {
|
|
940
|
+
return "unknown";
|
|
941
|
+
}
|
|
942
|
+
const typeCounts = {};
|
|
943
|
+
this._observations.forEach((obs) => {
|
|
944
|
+
if (variable in obs) {
|
|
945
|
+
const value = obs[variable];
|
|
946
|
+
const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
947
|
+
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
let maxCount = 0;
|
|
951
|
+
let dominantType = "unknown";
|
|
952
|
+
for (const [type, count] of Object.entries(typeCounts)) {
|
|
953
|
+
if (count > maxCount) {
|
|
954
|
+
maxCount = count;
|
|
955
|
+
dominantType = type;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return dominantType;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Verifies that the variable exists in each observation in the data.
|
|
962
|
+
*
|
|
963
|
+
* @remarks Throws an error if the variable does not exist in each
|
|
964
|
+
* observation. This is not meant to be called by users of the library, but
|
|
965
|
+
* is used internally.
|
|
966
|
+
* @internal
|
|
967
|
+
*
|
|
968
|
+
* @param variable - The variable to check for
|
|
969
|
+
*/
|
|
970
|
+
verifyObservationsContainVariable(variable) {
|
|
971
|
+
if (!this._observations.every((observation) => variable in observation)) {
|
|
972
|
+
throw new M2Error(
|
|
973
|
+
`Variable ${variable} does not exist for each item (row) in the data array.`
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Checks if the variable exists for at least one observation in the data.
|
|
979
|
+
*
|
|
980
|
+
* @remarks This is not meant to be called by users of the library, but
|
|
981
|
+
* is used internally.
|
|
982
|
+
* @internal
|
|
983
|
+
*
|
|
984
|
+
* @param variable - The variable to check for
|
|
985
|
+
* @returns true if the variable exists in at least one observation, false
|
|
986
|
+
* otherwise
|
|
987
|
+
*/
|
|
988
|
+
variableExists(variable) {
|
|
989
|
+
return this._observations.some((observation) => variable in observation);
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Checks if a value is a non-missing numeric value.
|
|
993
|
+
*
|
|
994
|
+
* @remarks A non-missing numeric value is a value that is a number and is
|
|
995
|
+
* not NaN or infinite.
|
|
996
|
+
*
|
|
997
|
+
* @param value - The value to check
|
|
998
|
+
* @returns true if the value is a non-missing numeric value, false otherwise
|
|
999
|
+
*/
|
|
1000
|
+
isNonMissingNumeric(value) {
|
|
1001
|
+
return typeof value === "number" && !isNaN(value) && isFinite(value);
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Checks if a value is a missing numeric value.
|
|
1005
|
+
*
|
|
1006
|
+
* @remarks A missing numeric value is a number that is NaN or infinite, or any
|
|
1007
|
+
* value that is null or undefined. Thus, a null or undefined value is
|
|
1008
|
+
* considered to be a missing numeric value.
|
|
1009
|
+
*
|
|
1010
|
+
* @param value - The value to check
|
|
1011
|
+
* @returns true if the value is a missing numeric value, false otherwise
|
|
1012
|
+
*/
|
|
1013
|
+
isMissingNumeric(value) {
|
|
1014
|
+
return typeof value === "number" && (isNaN(value) || !isFinite(value)) || value === null || typeof value === "undefined";
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Normalizes an object for stable comparison by sorting keys
|
|
1018
|
+
* @internal
|
|
1019
|
+
*
|
|
1020
|
+
* @remarks Normalizing is needed to handle situations where objects have the
|
|
1021
|
+
* same properties but in different orders because we are using
|
|
1022
|
+
* JSON.stringify() for comparison.
|
|
1023
|
+
*/
|
|
1024
|
+
normalizeForComparison(obj) {
|
|
1025
|
+
if (obj === null || typeof obj !== "object") {
|
|
1026
|
+
return obj;
|
|
1027
|
+
}
|
|
1028
|
+
if (Array.isArray(obj)) {
|
|
1029
|
+
return obj.map((item) => this.normalizeForComparison(item));
|
|
1030
|
+
}
|
|
1031
|
+
return Object.keys(obj).sort().reduce((result, key) => {
|
|
1032
|
+
result[key] = this.normalizeForComparison(obj[key]);
|
|
1033
|
+
return result;
|
|
1034
|
+
}, {});
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Creates a deep copy of an object.
|
|
1038
|
+
* @internal
|
|
1039
|
+
*
|
|
1040
|
+
* @remarks We create a deep copy of the object, in our case an instance
|
|
1041
|
+
* of `DataCalc`, to ensure that we are working with a new object
|
|
1042
|
+
* without any references to the original object. This is important
|
|
1043
|
+
* to avoid unintended side effects when modifying an object.
|
|
1044
|
+
*
|
|
1045
|
+
* @param source - object to copy
|
|
1046
|
+
* @param map - map of objects that have already been copied
|
|
1047
|
+
* @returns a deep copy of the object
|
|
1048
|
+
*/
|
|
1049
|
+
deepCopy(source, map = /* @__PURE__ */ new WeakMap()) {
|
|
1050
|
+
if (source === null || typeof source !== "object") {
|
|
1051
|
+
return source;
|
|
1052
|
+
}
|
|
1053
|
+
if (map.has(source)) {
|
|
1054
|
+
return map.get(source);
|
|
1055
|
+
}
|
|
1056
|
+
const copy = Array.isArray(source) ? [] : Object.create(Object.getPrototypeOf(source));
|
|
1057
|
+
map.set(source, copy);
|
|
1058
|
+
const keys = [
|
|
1059
|
+
...Object.getOwnPropertyNames(source),
|
|
1060
|
+
...Object.getOwnPropertySymbols(source)
|
|
1061
|
+
];
|
|
1062
|
+
for (const key of keys) {
|
|
1063
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
1064
|
+
source,
|
|
1065
|
+
key
|
|
1066
|
+
);
|
|
1067
|
+
if (descriptor) {
|
|
1068
|
+
Object.defineProperty(copy, key, {
|
|
1069
|
+
...descriptor,
|
|
1070
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1071
|
+
value: this.deepCopy(source[key], map)
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
return copy;
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Checks if an observation has null or undefined values in any of the join columns.
|
|
1079
|
+
* @internal
|
|
1080
|
+
*
|
|
1081
|
+
* @param obs - The observation to check
|
|
1082
|
+
* @param keys - The join columns to check
|
|
1083
|
+
* @returns true if any join column has a null or undefined value
|
|
1084
|
+
*/
|
|
1085
|
+
hasNullJoinKeys(obs, keys) {
|
|
1086
|
+
return keys.some((key) => obs[key] === null || obs[key] === void 0);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
console.log("\u26AA @m2c2kit/data-calc version 0.8.6 (c86b5047)");
|
|
1090
|
+
|
|
4
1091
|
class ColorShapes extends Game {
|
|
5
1092
|
constructor() {
|
|
6
1093
|
const defaultParameters = {
|
|
@@ -107,6 +1194,11 @@ class ColorShapes extends Game {
|
|
|
107
1194
|
type: ["string", "null"],
|
|
108
1195
|
default: null,
|
|
109
1196
|
description: "Optional seed for the seeded pseudo-random number generator. When null, the default Math.random() is used."
|
|
1197
|
+
},
|
|
1198
|
+
scoring: {
|
|
1199
|
+
type: "boolean",
|
|
1200
|
+
default: false,
|
|
1201
|
+
description: "Should scoring data be generated? Default is false."
|
|
110
1202
|
}
|
|
111
1203
|
};
|
|
112
1204
|
const colorShapesTrialSchema = {
|
|
@@ -223,6 +1315,43 @@ class ColorShapes extends Game {
|
|
|
223
1315
|
description: "Was the quit button pressed?"
|
|
224
1316
|
}
|
|
225
1317
|
};
|
|
1318
|
+
const colorShapesScoringSchema = {
|
|
1319
|
+
activity_begin_iso8601_timestamp: {
|
|
1320
|
+
type: "string",
|
|
1321
|
+
format: "date-time",
|
|
1322
|
+
description: "ISO 8601 timestamp at the beginning of the game activity."
|
|
1323
|
+
},
|
|
1324
|
+
first_trial_begin_iso8601_timestamp: {
|
|
1325
|
+
type: ["string", "null"],
|
|
1326
|
+
format: "date-time",
|
|
1327
|
+
description: "ISO 8601 timestamp at the beginning of the first trial. Null if no trials were completed."
|
|
1328
|
+
},
|
|
1329
|
+
last_trial_end_iso8601_timestamp: {
|
|
1330
|
+
type: ["string", "null"],
|
|
1331
|
+
format: "date-time",
|
|
1332
|
+
description: "ISO 8601 timestamp at the end of the last trial. Null if no trials were completed."
|
|
1333
|
+
},
|
|
1334
|
+
n_trials: {
|
|
1335
|
+
type: "integer",
|
|
1336
|
+
description: "Number of trials completed."
|
|
1337
|
+
},
|
|
1338
|
+
flag_trials_match_expected: {
|
|
1339
|
+
type: "integer",
|
|
1340
|
+
description: "Does the number of completed and expected trials match? 1 = true, 0 = false."
|
|
1341
|
+
},
|
|
1342
|
+
n_trials_correct: {
|
|
1343
|
+
type: "integer",
|
|
1344
|
+
description: "Number of correct trials."
|
|
1345
|
+
},
|
|
1346
|
+
n_trials_incorrect: {
|
|
1347
|
+
type: "integer",
|
|
1348
|
+
description: "Number of incorrect trials."
|
|
1349
|
+
},
|
|
1350
|
+
participant_score: {
|
|
1351
|
+
type: ["number", "null"],
|
|
1352
|
+
description: "Participant-facing score, calculated as (number of correct trials / number of trials attempted) * 100. This is a simple metric to provide feedback to the participant. Null if no trials attempted."
|
|
1353
|
+
}
|
|
1354
|
+
};
|
|
226
1355
|
const translation = {
|
|
227
1356
|
configuration: {
|
|
228
1357
|
baseLocale: "en-US"
|
|
@@ -287,8 +1416,8 @@ class ColorShapes extends Game {
|
|
|
287
1416
|
*/
|
|
288
1417
|
id: "color-shapes",
|
|
289
1418
|
publishUuid: "394cb010-2ccf-4a87-9d23-cda7fb07a960",
|
|
290
|
-
version: "0.8.
|
|
291
|
-
moduleMetadata: { "name": "@m2c2kit/assessment-color-shapes", "version": "0.8.
|
|
1419
|
+
version: "0.8.33 (c86b5047)",
|
|
1420
|
+
moduleMetadata: { "name": "@m2c2kit/assessment-color-shapes", "version": "0.8.33", "dependencies": { "@m2c2kit/addons": "0.3.34", "@m2c2kit/core": "0.3.35" } },
|
|
292
1421
|
translation,
|
|
293
1422
|
shortDescription: "Color Shapes is a visual array change detection task, measuring intra-item feature binding, where participants determine if shapes change color across two sequential presentations of shape stimuli.",
|
|
294
1423
|
longDescription: `Color Shapes is a change detection paradigm used to measure visual short-term memory binding (Parra et al., 2009). Participants are asked to memorize the shapes and colors of three different polygons for 3 seconds. The three polygons are then removed from the screen and re-displayed at different locations, either having the same or different colors. Participants are then asked to decide whether the combination of colors and shapes are the "Same" or "Different" between the study and test phases.`,
|
|
@@ -296,6 +1425,7 @@ class ColorShapes extends Game {
|
|
|
296
1425
|
width: 400,
|
|
297
1426
|
height: 800,
|
|
298
1427
|
trialSchema: colorShapesTrialSchema,
|
|
1428
|
+
scoringSchema: colorShapesScoringSchema,
|
|
299
1429
|
parameters: defaultParameters,
|
|
300
1430
|
fonts: [
|
|
301
1431
|
{
|
|
@@ -363,6 +1493,13 @@ class ColorShapes extends Game {
|
|
|
363
1493
|
game.presentScene(blankScene);
|
|
364
1494
|
game.addTrialData("quit_button_pressed", true);
|
|
365
1495
|
game.trialComplete();
|
|
1496
|
+
if (game.getParameter("scoring")) {
|
|
1497
|
+
const scores = game.calculateScores([], {
|
|
1498
|
+
numberOfTrials: game.getParameter("number_of_trials")
|
|
1499
|
+
});
|
|
1500
|
+
game.addScoringData(scores);
|
|
1501
|
+
game.scoringComplete();
|
|
1502
|
+
}
|
|
366
1503
|
game.cancel();
|
|
367
1504
|
});
|
|
368
1505
|
}
|
|
@@ -444,7 +1581,7 @@ class ColorShapes extends Game {
|
|
|
444
1581
|
break;
|
|
445
1582
|
}
|
|
446
1583
|
default: {
|
|
447
|
-
throw new M2Error("invalid value for instruction_type");
|
|
1584
|
+
throw new M2Error$1("invalid value for instruction_type");
|
|
448
1585
|
}
|
|
449
1586
|
}
|
|
450
1587
|
}
|
|
@@ -564,7 +1701,7 @@ class ColorShapes extends Game {
|
|
|
564
1701
|
"number_of_shapes_changing_color"
|
|
565
1702
|
);
|
|
566
1703
|
if (numberOfShapesToChange > numberOfShapesShown) {
|
|
567
|
-
throw new M2Error(
|
|
1704
|
+
throw new M2Error$1(
|
|
568
1705
|
`number_of_shapes_changing_color is ${numberOfShapesToChange}, but it must be less than or equal to number_of_shapes_shown (which is ${numberOfShapesShown}).`
|
|
569
1706
|
);
|
|
570
1707
|
}
|
|
@@ -778,14 +1915,25 @@ class ColorShapes extends Game {
|
|
|
778
1915
|
if (game.trialIndex < numberOfTrials) {
|
|
779
1916
|
game.presentScene(fixationScene);
|
|
780
1917
|
} else {
|
|
781
|
-
game.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
)
|
|
1918
|
+
if (game.getParameter("scoring")) {
|
|
1919
|
+
const scores = game.calculateScores(game.data.trials, {
|
|
1920
|
+
numberOfTrials: game.getParameter("number_of_trials")
|
|
1921
|
+
});
|
|
1922
|
+
game.addScoringData(scores);
|
|
1923
|
+
game.scoringComplete();
|
|
1924
|
+
}
|
|
1925
|
+
if (game.getParameter("show_trials_complete_scene")) {
|
|
1926
|
+
game.presentScene(
|
|
1927
|
+
doneScene,
|
|
1928
|
+
Transition.slide({
|
|
1929
|
+
direction: TransitionDirection.Left,
|
|
1930
|
+
duration: 500,
|
|
1931
|
+
easing: Easings.sinusoidalInOut
|
|
1932
|
+
})
|
|
1933
|
+
);
|
|
1934
|
+
} else {
|
|
1935
|
+
game.end();
|
|
1936
|
+
}
|
|
789
1937
|
}
|
|
790
1938
|
};
|
|
791
1939
|
const doneScene = new Scene();
|
|
@@ -810,6 +1958,23 @@ class ColorShapes extends Game {
|
|
|
810
1958
|
game.removeAllFreeNodes();
|
|
811
1959
|
});
|
|
812
1960
|
}
|
|
1961
|
+
calculateScores(data, extras) {
|
|
1962
|
+
const dc = new DataCalc(data);
|
|
1963
|
+
const scores = dc.summarize({
|
|
1964
|
+
activity_begin_iso8601_timestamp: this.beginIso8601Timestamp,
|
|
1965
|
+
first_trial_begin_iso8601_timestamp: dc.arrange("trial_begin_iso8601_timestamp").slice(0).pull("trial_begin_iso8601_timestamp"),
|
|
1966
|
+
last_trial_end_iso8601_timestamp: dc.arrange("-trial_end_iso8601_timestamp").slice(0).pull("trial_end_iso8601_timestamp"),
|
|
1967
|
+
n_trials: dc.length,
|
|
1968
|
+
flag_trials_match_expected: dc.length === extras.numberOfTrials ? 1 : 0,
|
|
1969
|
+
n_trials_correct: dc.filter((obs) => obs.user_response_correct === true).length,
|
|
1970
|
+
n_trials_incorrect: dc.filter(
|
|
1971
|
+
(obs) => obs.user_response_correct === false
|
|
1972
|
+
).length
|
|
1973
|
+
}).mutate({
|
|
1974
|
+
participant_score: (obs) => obs.n_trials > 0 ? obs.n_trials_correct / obs.n_trials * 100 : null
|
|
1975
|
+
});
|
|
1976
|
+
return scores.observations;
|
|
1977
|
+
}
|
|
813
1978
|
makeShapes(svgHeight) {
|
|
814
1979
|
const shape01 = new Shape({
|
|
815
1980
|
path: {
|