@qe-mcp/ena 0.0.0-stage → 0.1.18

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/webena.js ADDED
@@ -0,0 +1,651 @@
1
+ /**
2
+ * webena.js — the ENA Web Tool's conventions, so a model fit here comes out,
3
+ * plots and exports as the same model does in the web tool (rENA-webtool).
4
+ *
5
+ * Everything here is copied from the web tool and must be kept in step with it:
6
+ * group order, colours js/ena.ww.organize-data.js, js/global-ena.js DEF_COLORS / NA
7
+ * default rotation js/ena.controllers.js updateUnits (which flags turn on)
8
+ * js/WasmWrapper.js generate(), step 5 (the GMR it builds)
9
+ * default window js/ena.controllers.js (a new set takes the CCD window)
10
+ * points, groups js/WasmWrapper.js _formatResult (scaleUnits, group means,
11
+ * confidence intervals, group line weights)
12
+ * plots js/ena.qeviz.js buildPlot (the comparison plot)
13
+ * set file js/ENAmodel.js constructor (the stored set's defaults)
14
+ */
15
+
16
+ import { deflateRawSync } from 'zlib';
17
+
18
+ // ── Groups ────────────────────────────────────────────────────────────────────
19
+
20
+ // js/global-ena.js ENA.constants.DEF_COLORS: [color, secondaryColor] per group.
21
+ export const COLORS = [
22
+ ['#cc423a', '#56bd7c'],
23
+ ['#218ebf', '#ef691b'],
24
+ ['#9d5dbb', '#fbc848'],
25
+ ['#56bd7c', '#d0386c'],
26
+ ['#f18e9f', '#9a9eab'],
27
+ ['#ff8c39', '#346b88'],
28
+ ];
29
+ export const NODE_COLOR = '#4D4D4D';
30
+ const SHOW_MEAN_INDEX = 20; // ENA.constants.SHOW_MEAN_INDEX
31
+ const NA = new Set(['#N/A', 'NA', '']); // ENA.constants.NA
32
+
33
+ export const isNA = (v) => v == null || NA.has(String(v));
34
+
35
+ // The web tool groups units by the values of their first unit column, in the
36
+ // order a JS object keeps its keys (organize-data's `for (key in grouped)`):
37
+ // integer-like values first, ascending, then the rest in order of appearance.
38
+ export function groupOrder(rows, col) {
39
+ const seen = new Set();
40
+ for (const r of rows) if (!isNA(r[col])) seen.add(String(r[col]));
41
+ const isIndex = (s) => /^(0|[1-9]\d*)$/.test(s) && Number(s) < 4294967295;
42
+ const all = [...seen];
43
+ return [...all.filter(isIndex).sort((a, b) => a - b), ...all.filter(s => !isIndex(s))];
44
+ }
45
+
46
+ // Colour and mean flags of the i-th top-level group (organize-data).
47
+ export function groupStyle(i) {
48
+ const [color, secondaryColor] = COLORS[i % COLORS.length];
49
+ return { color, secondaryColor, showMean: i < SHOW_MEAN_INDEX };
50
+ }
51
+
52
+ // The groups the web tool makes of these units: the first unit column's values
53
+ // when there are two or more unit columns; with one column every unit sits in
54
+ // a single group named after it (organize-data's singleConditionWrapper).
55
+ export function unitGroups(rows, unitCols) {
56
+ if (unitCols.length < 2) return { col: null, order: [unitCols[0]], wrapper: true };
57
+ return { col: unitCols[0], order: groupOrder(rows, unitCols[0]), wrapper: false };
58
+ }
59
+
60
+ // ── Default rotation ──────────────────────────────────────────────────────────
61
+
62
+ // With two or more groups the web tool switches regressionRotated on (and
63
+ // meansRotated with exactly two), seeds meansRotatedBy with the first two
64
+ // groups, and fits a generalized means rotation (GMR) through them.
65
+ export function defaultRotation(groups) {
66
+ if (groups.wrapper || groups.order.length < 2) return null;
67
+ return { col: groups.col, pair: groups.order.slice(0, 2), nGroups: groups.order.length };
68
+ }
69
+
70
+ // rena-wasm GMR parameters exactly as WasmWrapper builds them for a regression
71
+ // rotation: one categorical target (each unit's value on its first row),
72
+ // levels = the pair then the other values in order of appearance, treatment
73
+ // coding against the first of the pair, GMR1 fit on the pair's units.
74
+ export function gmrParams(rows, unitCols, unitLabels, targetCol, pair) {
75
+ const firstRow = new Map();
76
+ for (const r of rows) {
77
+ const key = unitCols.map(c => String(r[c])).join('__');
78
+ if (!firstRow.has(key)) firstRow.set(key, r);
79
+ }
80
+ const n = unitLabels.length;
81
+ const vals = unitLabels.map(l => String(firstRow.get(l)?.[targetCol] ?? '').trim());
82
+ const clean = pair.map(g => String(g).trim());
83
+ for (const g of clean)
84
+ if (!vals.includes(g))
85
+ throw new Error(`No modeled unit has ${targetCol}='${g}'.`);
86
+ const levels = [...clean, ...[...new Set(vals)].filter(v => !clean.includes(v))];
87
+ const nDummies = levels.length - 1;
88
+
89
+ const xTarget = new Float64Array(n);
90
+ const xModelMatrix = new Float64Array(n * nDummies);
91
+ const subset = [];
92
+ for (let u = 0; u < n; u++) {
93
+ const lvl = levels.indexOf(vals[u]);
94
+ xTarget[u] = lvl >= 0 ? lvl : levels.length - 1;
95
+ if (lvl > 0) xModelMatrix[u * nDummies + (lvl - 1)] = 1;
96
+ if (clean.includes(vals[u])) subset.push(u);
97
+ }
98
+ if (!subset.length) for (let u = 0; u < n; u++) subset.push(u);
99
+
100
+ return {
101
+ levels,
102
+ subsetSize: subset.length,
103
+ gParams: {
104
+ xModelMatrix, xmRows: n, xmCols: nDummies,
105
+ xTarget,
106
+ x1Cols: new Int32Array(nDummies).map((_, i) => i),
107
+ xCategorical: true,
108
+ xNGroups: levels.length,
109
+ xSubset: new Int32Array(subset),
110
+ hasY: false,
111
+ // Stub Y params, as WasmWrapper passes them (ignored when hasY=false).
112
+ yModelMatrix: new Float64Array(n), ymRows: n, ymCols: 1,
113
+ yTarget: new Float64Array(n),
114
+ y1Cols: new Int32Array([0]),
115
+ yCategorical: false,
116
+ yNGroups: 0,
117
+ },
118
+ };
119
+ }
120
+
121
+ // A new set in the web tool takes the window that Cross-Covariance Decay
122
+ // recommends (WasmWrapper ccdWindow: maxWindow 20, minOverlap 10).
123
+ export const CCD_OPTS = { maxWindow: 20, minOverlap: 10 };
124
+ export const FALLBACK_WINDOW = 4;
125
+
126
+ // ── Points and groups ─────────────────────────────────────────────────────────
127
+
128
+ const CI_LEVEL = 0.95;
129
+
130
+ // WasmWrapper's scaleUnits factor: on the first two dimensions, the ratio of
131
+ // the code nodes' extent to the points' (largest |value|, and smallest
132
+ // non-zero |value|), s = min(max(min-ratios), max(max-ratios)).
133
+ export function pointScale(points, nodes, nUnits, nCodes, stride) {
134
+ const dimsToScale = Math.min(2, stride);
135
+ const maxRatios = [], minRatios = [];
136
+ for (let d = 0; d < dimsToScale; d++) {
137
+ const pt = Array.from({ length: nUnits }, (_, i) => Math.abs(points[i * stride + d]));
138
+ const nd = Array.from({ length: nCodes }, (_, i) => Math.abs(nodes[i * stride + d]));
139
+ const ptNZ = pt.filter(v => v > 0), ndNZ = nd.filter(v => v > 0);
140
+ const pMax = Math.max(...pt, 0) || 1;
141
+ const nMax = Math.max(...nd, 0) || 1;
142
+ const pMin = (ptNZ.length ? Math.min(...ptNZ) : pMax) || 1;
143
+ const nMin = (ndNZ.length ? Math.min(...ndNZ) : nMax) || 1;
144
+ maxRatios.push(Math.abs(nMax / pMax));
145
+ minRatios.push(Math.abs(nMin / pMin));
146
+ }
147
+ const fin = (a) => a.filter(v => Number.isFinite(v));
148
+ const mx = fin(maxRatios), mn = fin(minRatios);
149
+ if (!mx.length || !mn.length) return 1;
150
+ const s = Math.min(Math.max(...mn), Math.max(...mx));
151
+ return Number.isFinite(s) && s > 0 ? s : 1;
152
+ }
153
+
154
+ // WasmWrapper._scale: non-zero values rescaled linearly into [lo, hi], zeros
155
+ // kept at 0; a constant non-zero array becomes all `hi`.
156
+ export function rescale(arr, lo, hi) {
157
+ const nz = arr.filter(v => v !== 0);
158
+ if (!nz.length) return arr.map(() => 0);
159
+ const min = Math.min(...nz), max = Math.max(...nz);
160
+ if (max === min) return arr.map(v => (v === 0 ? 0 : hi));
161
+ return arr.map(v => (v === 0 ? 0 : lo + (v - min) * (hi - lo) / (max - min)));
162
+ }
163
+
164
+ function colMeans(flat, rowIdx, nCols) {
165
+ const out = new Array(nCols).fill(0);
166
+ if (!rowIdx.length) return out;
167
+ for (const i of rowIdx) for (let j = 0; j < nCols; j++) out[j] += flat[i * nCols + j];
168
+ return out.map(v => v / rowIdx.length);
169
+ }
170
+
171
+ /**
172
+ * What the web tool computes for its plots after a fit:
173
+ * scale the scaleUnits factor
174
+ * points unit points × scale (nUnits × stride, flat)
175
+ * groups per group: unit indices, mean of the scaled points, 95%
176
+ * confidence bounds and outlier half-spans (Tukey fences, 1.5 × IQR)
177
+ * per dimension, the mean line weights rescaled
178
+ * to [0.1, 1] (meanOrSum = mean), colour and mean flags
179
+ * `members(i)` gives a unit's group name; `names` the groups, in order.
180
+ */
181
+ export function webSet(ena, model, unitLabels, names, members) {
182
+ const stride = model.dims ?? 2;
183
+ const nUnits = unitLabels.length;
184
+ const nCodes = model.positions.length / stride;
185
+ const scale = pointScale(model.centroids, model.positions, nUnits, nCodes, stride);
186
+ const points = Float64Array.from(model.centroids, v => v * scale);
187
+
188
+ const groups = names.map((name, gi) => {
189
+ const idx = unitLabels.flatMap((_, i) => (members(i) === name ? [i] : []));
190
+ const mean = colMeans(points, idx, stride);
191
+ let ci = null, oi = null;
192
+ if (idx.length > 1) {
193
+ const flat = new Float64Array(idx.length * stride);
194
+ idx.forEach((u, r) => { for (let d = 0; d < stride; d++) flat[r * stride + d] = points[u * stride + d]; });
195
+ try {
196
+ const res = ena.confInts(flat, idx.length, stride, CI_LEVEL);
197
+ ci = Array.from({ length: stride }, (_, d) => [res.data[d * 3 + 1], res.data[d * 3 + 2]]);
198
+ } catch { ci = null; }
199
+ // js/WasmWrapper.js: outlier.ints[d] = (upper fence − lower fence) / 2,
200
+ // drawn as ± that around the group mean.
201
+ try {
202
+ const res = ena.outlierInts(flat, idx.length, stride, 1.5);
203
+ oi = Array.from({ length: stride }, (_, d) => (res.data[d * 2 + 1] - res.data[d * 2]) / 2);
204
+ } catch { oi = null; }
205
+ }
206
+ const lineWeights = rescale(colMeans(model.networks, idx, model.nConnections), 0.1, 1);
207
+ return { name, idx, mean, ci, oi, lineWeights, n: idx.length, ...groupStyle(gi) };
208
+ });
209
+ return { scale, points, stride, groups, byName: new Map(groups.map(g => [g.name, g])) };
210
+ }
211
+
212
+ // ── Plots (js/ena.qeviz.js buildPlot, the comparison plot) ────────────────────
213
+
214
+ const RANGE_MODIFIER = 1.2;
215
+ const EDGE_PX = 7.5; // a weight-1 edge, px (× "Scale edge weights")
216
+ const SUBTRACTION_MAX_PX = 4; // a subtraction's strongest edge, px
217
+ const NODE_PX = 4.75; // code-node radius per unit of Σ|weight|, px
218
+ export const TEXT_SIZE = 12;
219
+ const LABEL_COLOR = '#000000';
220
+ const LABEL_FONT = 'arial';
221
+ const UNIT_SET = 'units';
222
+
223
+ const finite = (v) => typeof v === 'number' && Number.isFinite(v);
224
+
225
+ function frame(columns, types, rows) {
226
+ return {
227
+ data: rows.map(r => Object.fromEntries(columns.map(c => [c, r[c] === undefined ? null : r[c]]))),
228
+ types: Object.fromEntries(columns.map(c => [c, types[c] || 'numeric'])),
229
+ };
230
+ }
231
+
232
+ // The web tool shows unit paths with '::'; rena-wasm labels join with '__'.
233
+ export const unitPath = (label) => label.split('__').join('::');
234
+
235
+ /**
236
+ * Build one plot as the web tool's comparison plot draws it.
237
+ * entry the cached model: model, codes, unitLabels, web (webSet), dimNames, variance
238
+ * view { first, second, overlay, scale, axes, flip, style }: first/second
239
+ * are { kind: 'group' | 'unit' | 'all', name }; with a second the
240
+ * plot draws first − second, or (overlay: true) both networks.
241
+ * style the web tool's Plot Settings: { minWeight (minimumThreshold),
242
+ * hideUnconnected (!showUnconnectedCodes), textSize, colors
243
+ * ({ group: colour }), ci (confidenceIntervals, default on),
244
+ * outliers (outlierIntervals, default off) }.
245
+ * Returns { data, layers, attrs, edgePx, title, maxWeight, scale, subtract };
246
+ * the renderer
247
+ * sets edge-width = edgePx ÷ the plot's shorter side in px. Group rows carry
248
+ * both the CI and the outlier bounds whichever is shown, so a viewer can
249
+ * toggle them.
250
+ */
251
+ export function buildPlot(entry, view = {}) {
252
+ const { model, codes, unitLabels, web } = entry;
253
+ const stride = web.stride;
254
+ const ordered = model.ordered === true;
255
+ const scale = Number(view.scale) > 0 ? Number(view.scale) : 1;
256
+ const conn = model.connectionNames.map(n => n.replace(' & ', '.'));
257
+ const pairs = model.connectionNames.map(n => n.split(' & '));
258
+ const style = view.style ?? {};
259
+ const colorOf = (g) => style.colors?.[g.name] ?? g.color;
260
+
261
+ const all = () => {
262
+ const idx = unitLabels.map((_, i) => i);
263
+ return rescale(colMeans(model.networks, idx, model.nConnections), 0.1, 1);
264
+ };
265
+ const network = (sel) => {
266
+ if (!sel) return null;
267
+ if (sel.kind === 'unit') {
268
+ const i = unitLabels.indexOf(sel.name);
269
+ if (i < 0) throw new Error(`Unit '${sel.name}' not found.`);
270
+ const g = web.groups.find(gr => gr.idx.includes(i));
271
+ return {
272
+ name: unitPath(sel.name),
273
+ w: Array.from(model.networks.slice(i * model.nConnections, (i + 1) * model.nConnections)),
274
+ color: g ? colorOf(g) : NODE_COLOR, secondaryColor: g?.secondaryColor ?? NODE_COLOR,
275
+ };
276
+ }
277
+ if (sel.kind === 'all') {
278
+ const only = web.groups.length === 1 ? web.groups[0] : null;
279
+ return { name: only?.name ?? 'All units', w: only?.lineWeights ?? all(),
280
+ color: only ? colorOf(only) : NODE_COLOR, secondaryColor: only?.secondaryColor ?? NODE_COLOR };
281
+ }
282
+ const g = web.byName.get(String(sel.name));
283
+ if (!g) throw new Error(`Group '${sel.name}' not found. Groups: ${web.groups.map(x => x.name).join(', ')}`);
284
+ return { name: g.name, w: g.lineWeights, color: colorOf(g), secondaryColor: g.secondaryColor };
285
+ };
286
+
287
+ // Axes: any two of the model's dimensions, either flipped (the web tool's
288
+ // Plot Settings dimension choice and Plot Tools Flip X / Flip Y).
289
+ const [ax, ay] = view.axes ?? [0, 1];
290
+ const [sx, sy] = (view.flip ?? [false, false]).map(f => (f ? -1 : 1));
291
+ const box = ([lo, hi], sgn) => (sgn > 0 ? [lo, hi] : [-hi, -lo]);
292
+
293
+ const first = network(view.first);
294
+ const second = network(view.second);
295
+ const subtract = !!(first && second && !view.overlay);
296
+ const secondColor = second && first && second.color === first.color ? second.secondaryColor : second?.color;
297
+
298
+ // ── networks ──
299
+ const networks = [];
300
+ const weightsOf = (net, minus) => Object.fromEntries(conn.map((c, j) => [c, net.w[j] - (minus ? minus.w[j] : 0)]));
301
+ let primaryWeights = null;
302
+ if (first) {
303
+ if (subtract) {
304
+ const name = `${first.name} - ${second.name}`;
305
+ primaryWeights = weightsOf(first, second);
306
+ networks.push({ name, weights: primaryWeights, colors: [first.color, secondColor] });
307
+ } else {
308
+ primaryWeights = weightsOf(first);
309
+ networks.push({ name: first.name, weights: primaryWeights, colors: [first.color] });
310
+ if (second) networks.push({ name: second.name, weights: weightsOf(second), colors: [secondColor] });
311
+ }
312
+ }
313
+
314
+ // ── range: unit points and CI boxes on the plotted dims ──
315
+ let extent = 0;
316
+ const grow = (v) => { if (finite(v)) extent = Math.max(extent, Math.abs(v)); };
317
+ for (let i = 0; i < unitLabels.length; i++) { grow(web.points[i * stride + ax]); grow(web.points[i * stride + ay]); }
318
+ for (const g of web.groups) if (g.ci && g.ci[ax] && g.ci[ay]) [...g.ci[ax], ...g.ci[ay]].forEach(grow);
319
+ if (!extent) for (let c = 0; c < codes.length; c++) { grow(model.positions[c * stride + ax]); grow(model.positions[c * stride + ay]); }
320
+
321
+ // ── nodes: size = 2 × (4.75 × Σ|incident weight| × scale + 1) px ──
322
+ const nodeRows = codes.map((code, c) => {
323
+ const row = { code, label: code, x: sx * model.positions[c * stride + ax], y: sy * model.positions[c * stride + ay] };
324
+ if (primaryWeights) {
325
+ let sum = 0;
326
+ pairs.forEach(([src, tgt], j) => {
327
+ if ((src === code && !ordered) || tgt === code) sum += Math.abs(primaryWeights[conn[j]] || 0);
328
+ });
329
+ row.size = 2 * (NODE_PX * sum * scale + 1);
330
+ }
331
+ return row;
332
+ });
333
+
334
+ // ── unit points, coloured by group ──
335
+ const groupOfUnit = new Map();
336
+ web.groups.forEach(g => g.idx.forEach(i => groupOfUnit.set(i, g)));
337
+ const pinned = new Set([view.first, view.second].filter(s => s?.kind === 'unit').map(s => unitPath(s.name)));
338
+ const pointRows = unitLabels.map((lbl, i) => ({
339
+ set: UNIT_SET, id: unitPath(lbl),
340
+ x: sx * web.points[i * stride + ax], y: sy * web.points[i * stride + ay],
341
+ color: groupOfUnit.has(i) ? colorOf(groupOfUnit.get(i)) : NODE_COLOR,
342
+ label: unitPath(lbl), 'label.pinned': pinned.has(unitPath(lbl)),
343
+ }));
344
+
345
+ // ── group means with 95% CI boxes ──
346
+ const groupRows = web.groups.filter(g => g.showMean && g.n > 0).map(g => {
347
+ const x = sx * g.mean[ax], y = sy * g.mean[ay];
348
+ const row = { group: g.name, x, y, color: colorOf(g), intervals: 'none', outlier: false, label: g.name };
349
+ const ci = g.ci;
350
+ if (ci && ci[ax] && ci[ay] && [ci[ax][0], ci[ax][1], ci[ay][0], ci[ay][1]].every(finite) && g.n > 1) {
351
+ const [xl, xh] = box(ci[ax], sx), [yl, yh] = box(ci[ay], sy);
352
+ Object.assign(row, { intervals: style.ci === false ? 'none' : 'box', 'x.low': xl, 'x.high': xh, 'y.low': yl, 'y.high': yh });
353
+ }
354
+ const oi = g.oi;
355
+ if (oi && finite(oi[ax]) && finite(oi[ay])) {
356
+ const ox = Math.abs(oi[ax]), oy = Math.abs(oi[ay]);
357
+ Object.assign(row, { outlier: style.outliers === true, 'x.olow': x - ox, 'x.ohigh': x + ox, 'y.olow': y - oy, 'y.ohigh': y + oy });
358
+ }
359
+ return row;
360
+ });
361
+
362
+ const LABELS = ['label', 'label.dx', 'label.dy'];
363
+ const data = {
364
+ directed: ordered,
365
+ updated: Date.now(),
366
+ nodes: frame(['code', 'x', 'y', 'size', ...LABELS], { code: 'character', label: 'character' }, nodeRows),
367
+ node_id_col: 'code',
368
+ x_col: 'x',
369
+ y_col: 'y',
370
+ pointsets: frame(['set', 'id', 'x', 'y', 'color', ...LABELS, 'label.pinned'],
371
+ { set: 'character', id: 'character', color: 'character', label: 'character', 'label.pinned': 'logical' }, pointRows),
372
+ };
373
+ if (groupRows.length)
374
+ data.groups = frame(['group', 'x', 'y', 'color', 'intervals', 'outlier', 'x.low', 'x.high', 'y.low', 'y.high',
375
+ 'x.olow', 'x.ohigh', 'y.olow', 'y.ohigh', ...LABELS],
376
+ { group: 'character', color: 'character', intervals: 'character', outlier: 'logical', label: 'character' }, groupRows);
377
+ if (networks.length) {
378
+ const cols = ['name', ...conn];
379
+ data.networks = frame(cols, { name: 'character' },
380
+ networks.map(n => ({ name: n.name, ...n.weights })));
381
+ }
382
+
383
+ // ── layers ──
384
+ const axisLabel = (d) => {
385
+ const name = entry.dimNames?.[d];
386
+ const v = entry.variance?.[d];
387
+ if (!name) return null;
388
+ return finite(v) ? `${name} (${(v * 100).toFixed(2)}%)` : name;
389
+ };
390
+ const layers = [['qe-axes', { 'fixed-size': '', x: axisLabel(ax), y: axisLabel(ay) }]];
391
+ if (networks.length) {
392
+ layers.push(['qe-nodes', { label: 'on', 'label-font-weight': 'bold',
393
+ unconnected: style.hideUnconnected ? 'hide' : null, 'fixed-size': '' }]);
394
+ for (const n of networks)
395
+ layers.push(['qe-edges', {
396
+ network: n.name, name: n.name, colors: n.colors.join(','),
397
+ threshold: style.minWeight > 0 ? String(style.minWeight) : null,
398
+ 'self-size': ordered ? 'node' : null, 'color-scale': 'plot', 'fixed-size': '',
399
+ }]);
400
+ }
401
+ layers.push(['qe-points', { set: UNIT_SET, label: 'auto', 'fixed-size': '' }]);
402
+ if (groupRows.length) layers.push(['qe-means', { 'fixed-size': '', label: 'on' }]);
403
+
404
+ let edgePx = EDGE_PX * scale;
405
+ if (subtract) {
406
+ const maxDiff = Math.max(0, ...Object.values(primaryWeights).map(Math.abs));
407
+ if (maxDiff > 0) edgePx = SUBTRACTION_MAX_PX / maxDiff;
408
+ }
409
+
410
+ const title = !first ? 'All units'
411
+ : (subtract ? `${first.name} – ${second.name}`
412
+ : second ? `${first.name} + ${second.name}` : first.name) + ` (scaled ${scale.toFixed(1)}x)`;
413
+
414
+ const maxWeight = Math.max(0, ...networks.flatMap(n => Object.values(n.weights).map(Math.abs).filter(finite)));
415
+
416
+ return {
417
+ data, layers, edgePx, title, maxWeight, scale, subtract,
418
+ attrs: {
419
+ range: extent > 0 ? String(extent * RANGE_MODIFIER) : null,
420
+ 'view-padding': '1',
421
+ 'zoom-step': '1.5', 'zoom-min': String(1 / 15), 'zoom-max': '100',
422
+ 'point-scale': '1',
423
+ 'label-font-size': String(style.textSize > 0 ? style.textSize : TEXT_SIZE),
424
+ 'label-font-color': LABEL_COLOR,
425
+ 'label-font-family': LABEL_FONT,
426
+ },
427
+ };
428
+ }
429
+
430
+ // ── Set file (js/ENAmodel.js constructor defaults) ────────────────────────────
431
+
432
+ export const SET_FORMAT_VERSION = 1;
433
+
434
+ const MODEL_SETTINGS = {
435
+ stanza: 'moving', stanzaSize: 4, nodePositions: 'optimal', trajectoryBy: 'Activity',
436
+ rotationMatrixSet: null, weightModel: false, weightModelBy: null,
437
+ autoUpdateRotations: true, meansRotated: 1, regressionRotated: 1,
438
+ customRegression: false, customRegressionFormula: '', yRegressionFormula: null,
439
+ meansRotatedBy: [], sphereNorm: true, codeVariablesBinary: false, ordered: false,
440
+ pria: false, priaThreshold: 0.95, alwaysAskNAs: true, createGroupDialog: true,
441
+ priaDialog: true, codeMask: null, codeMaskLabels: null, centerZero: true,
442
+ hoo_flexible: false, derivedCodes: [], derivedStrategy: 'prime',
443
+ };
444
+ const TOOL_SETTINGS = {
445
+ dimensionLabels: true, xAxis: null, yAxis: null, varianceExplained: true,
446
+ scaleUnits: true, unitLabels: false, groupLabels: true, confidenceIntervals: true,
447
+ outlierIntervals: false, groups: true, comparisonNetwork: true, codeLabels: true,
448
+ showUnconnectedCodes: true, connectionWeights: true, minimumThreshold: 0,
449
+ scaleLineWeights: 1, yFlip: false, xFlip: false, dimensionX: null, dimensionY: null,
450
+ displayBy: null, intensityBy: 'set', intensityMinimum: 0, intensityMaximum: 1,
451
+ textSize: TEXT_SIZE, meanOrSum: true,
452
+ };
453
+ const STATS_SETTINGS = {
454
+ significantDigits: 2, type: null, comparison: { first: null, second: null },
455
+ results: {}, inputText: null, Correlation1: null, Correlation2: null,
456
+ };
457
+
458
+ /**
459
+ * The stored set the web tool imports: its own unified set format, with the
460
+ * web tool's defaults and this model's choices applied.
461
+ * s = { setName, csvName, units, conversation, codes, groups: [{ name, color,
462
+ * secondaryColor, showMean }], groupCol, window (number | Infinity),
463
+ * weightModel, sphereNorm, ordered, rotation: { meansRotated,
464
+ * regressionRotated, meansRotatedBy }, plotted: { primary, secondary },
465
+ * source, unitTree?, convoTree?, order? }
466
+ * unitTree: [{ values: [unit column values...], include }] for every unit in
467
+ * the data, when some are excluded (units_used). The set then stores the
468
+ * whole units tree with each node's include flag: the web tool rebuilds the
469
+ * tree from the data on load and copies flags from stored nodes by path
470
+ * (js/ena.ww.organize-data.js), and models only included units (unitsUsed,
471
+ * js/WasmWrapper.js generate()). Every level is stored so each rebuilt node
472
+ * has an exact match — a missing intermediate level would take its flag
473
+ * from the first stored node below it.
474
+ */
475
+ function unitNodes(unitTree, unitCols, groups, depth = 0, prefix = []) {
476
+ const byValue = new Map();
477
+ for (const u of unitTree) {
478
+ const v = String(u.values[depth]);
479
+ if (!byValue.has(v)) byValue.set(v, []);
480
+ byValue.get(v).push(u);
481
+ }
482
+ const leaf = depth === unitCols.length - 1;
483
+ return [...byValue.entries()].map(([name, members], index) => {
484
+ const pathArr = [...prefix, name];
485
+ const items = leaf ? [] : unitNodes(members, unitCols, groups, depth + 1, pathArr);
486
+ const g = depth === 0 ? groups.find(x => x.name === name) : null;
487
+ return {
488
+ name, path: pathArr.join('::'), pathArr, condition: unitCols[depth], leaf, items, index,
489
+ include: members.some(m => m.include), show: true, showLabel: true,
490
+ isCustomGroup: false, singleConditionWrapper: false,
491
+ ...(g && { color: g.color, secondaryColor: g.secondaryColor, showMean: g.showMean,
492
+ showConfidence: true, showOutline: false, showHiddenPoints: false }),
493
+ };
494
+ });
495
+ }
496
+
497
+ export function setFile(s) {
498
+ const now = Date.now();
499
+ const infinite = s.window === Infinity;
500
+ return {
501
+ formatVersion: SET_FORMAT_VERSION,
502
+ source: s.source,
503
+ setName: s.setName,
504
+ sampleSet: false,
505
+ description: s.description ?? null,
506
+ parentSet: { projectKey: null, setID: null },
507
+ created: now,
508
+ modified: now,
509
+ changeText: 'Set Created',
510
+ changeLocation: 'Projects',
511
+ deleted: false,
512
+ dataRef: s.csvName,
513
+ enasetRef: null,
514
+ set: null,
515
+ rotationSet: null,
516
+ model: {
517
+ units: [...s.units], codes: [...s.codes], conversation: [...s.conversation],
518
+ hoo: {}, group: null, groups: [], columns: {}, utterance: null, rotationSet: null,
519
+ },
520
+ selectionsRef: null,
521
+ selections: {
522
+ // Top-level groups only: the web tool builds the units under them
523
+ // from the data, keeping these groups' colours and flags. With
524
+ // excluded units, the whole tree (see unitNodes).
525
+ units: s.unitTree ? unitNodes(s.unitTree, s.units, s.groups) : s.groups.map((g, i) => ({
526
+ name: g.name, path: g.name, pathArr: [g.name],
527
+ condition: s.groupCol, leaf: false, items: [], index: i,
528
+ include: true, show: true, isCustomGroup: false, singleConditionWrapper: false,
529
+ color: g.color, secondaryColor: g.secondaryColor,
530
+ showMean: g.showMean, showConfidence: true, showOutline: false,
531
+ showHiddenPoints: false, showLabel: true,
532
+ })),
533
+ // Excluded horizons (exclude_conversations): the whole conversation
534
+ // tree with include flags, which WasmWrapper._conversationsUsed()
535
+ // reads (last-column nodes with include === true).
536
+ conversation: s.convoTree ? unitNodes(s.convoTree, s.conversation, []) : [],
537
+ codes: s.codes.map((label, index) => ({ label, label_r: label, show: true, showLabel: true, include: true, index })),
538
+ groups: [],
539
+ stanzas: [],
540
+ },
541
+ settings: {
542
+ model: {
543
+ ...structuredClone(MODEL_SETTINGS),
544
+ stanza: infinite ? 'infinite' : 'moving',
545
+ stanzaSize: infinite ? FALLBACK_WINDOW : s.window,
546
+ windowType: 'lines',
547
+ weightModel: !!s.weightModel,
548
+ weightModelBy: s.weightModel ?? null,
549
+ sphereNorm: s.sphereNorm !== false,
550
+ ordered: !!s.ordered,
551
+ meansRotated: s.rotation.meansRotated,
552
+ regressionRotated: s.rotation.regressionRotated,
553
+ meansRotatedBy: [...s.rotation.meansRotatedBy],
554
+ // Window order (WasmWrapper._orderColumn): a column, or the default.
555
+ ...(s.order && { order: s.order }),
556
+ // The Code Masking Matrix, positional over codeMaskLabels
557
+ // (WasmWrapper._alignCodeMask passes it to rena-wasm as is).
558
+ ...(s.codeMask && { codeMask: s.codeMask.map(r => [...r]), codeMaskLabels: [...s.codes] }),
559
+ },
560
+ tools: structuredClone(TOOL_SETTINGS),
561
+ stats: {
562
+ ...structuredClone(STATS_SETTINGS),
563
+ comparison: { first: s.plotted.primary ?? null, second: s.plotted.secondary ?? null },
564
+ },
565
+ view: {
566
+ plottedNodes: { primary: s.plotted.primary ?? null, secondary: s.plotted.secondary ?? null },
567
+ selectedCodes: [],
568
+ dataViewPlot: 'primary',
569
+ },
570
+ },
571
+ };
572
+ }
573
+
574
+ // ── ZIP (stored entries deflated; no dependency) ──────────────────────────────
575
+
576
+ const CRC_TABLE = (() => {
577
+ const t = new Uint32Array(256);
578
+ for (let n = 0; n < 256; n++) {
579
+ let c = n;
580
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
581
+ t[n] = c >>> 0;
582
+ }
583
+ return t;
584
+ })();
585
+
586
+ function crc32(buf) {
587
+ let c = 0xFFFFFFFF;
588
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
589
+ return (c ^ 0xFFFFFFFF) >>> 0;
590
+ }
591
+
592
+ function dosTime(d) {
593
+ return {
594
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1),
595
+ date: ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
596
+ };
597
+ }
598
+
599
+ /** entries: [{ name, data: Buffer | string }] (a name ending in '/' is a folder). */
600
+ export function zip(entries) {
601
+ const { time, date } = dosTime(new Date());
602
+ const locals = [], centrals = [];
603
+ let offset = 0;
604
+ for (const e of entries) {
605
+ const name = Buffer.from(e.name, 'utf8');
606
+ const raw = e.name.endsWith('/') ? Buffer.alloc(0) : Buffer.isBuffer(e.data) ? e.data : Buffer.from(e.data ?? '', 'utf8');
607
+ const deflated = raw.length ? deflateRawSync(raw) : raw;
608
+ const method = raw.length ? 8 : 0;
609
+ const crc = crc32(raw);
610
+
611
+ const local = Buffer.alloc(30);
612
+ local.writeUInt32LE(0x04034b50, 0);
613
+ local.writeUInt16LE(20, 4);
614
+ local.writeUInt16LE(0x0800, 6); // UTF-8 names
615
+ local.writeUInt16LE(method, 8);
616
+ local.writeUInt16LE(time, 10);
617
+ local.writeUInt16LE(date, 12);
618
+ local.writeUInt32LE(crc, 14);
619
+ local.writeUInt32LE(deflated.length, 18);
620
+ local.writeUInt32LE(raw.length, 22);
621
+ local.writeUInt16LE(name.length, 26);
622
+ local.writeUInt16LE(0, 28);
623
+ locals.push(local, name, deflated);
624
+
625
+ const central = Buffer.alloc(46);
626
+ central.writeUInt32LE(0x02014b50, 0);
627
+ central.writeUInt16LE(20, 4);
628
+ central.writeUInt16LE(20, 6);
629
+ central.writeUInt16LE(0x0800, 8);
630
+ central.writeUInt16LE(method, 10);
631
+ central.writeUInt16LE(time, 12);
632
+ central.writeUInt16LE(date, 14);
633
+ central.writeUInt32LE(crc, 16);
634
+ central.writeUInt32LE(deflated.length, 20);
635
+ central.writeUInt32LE(raw.length, 24);
636
+ central.writeUInt16LE(name.length, 28);
637
+ central.writeUInt32LE(e.name.endsWith('/') ? 0x10 : 0, 38); // directory attribute
638
+ central.writeUInt32LE(offset, 42);
639
+ centrals.push(central, name);
640
+
641
+ offset += local.length + name.length + deflated.length;
642
+ }
643
+ const centralSize = centrals.reduce((n, b) => n + b.length, 0);
644
+ const end = Buffer.alloc(22);
645
+ end.writeUInt32LE(0x06054b50, 0);
646
+ end.writeUInt16LE(entries.length, 8);
647
+ end.writeUInt16LE(entries.length, 10);
648
+ end.writeUInt32LE(centralSize, 12);
649
+ end.writeUInt32LE(offset, 16);
650
+ return Buffer.concat([...locals, ...centrals, end]);
651
+ }