@gkucmierz/utils 5.4.0 → 5.4.2

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/main.mjs CHANGED
@@ -38,6 +38,9 @@ import {
38
38
  import {
39
39
  consumeIteratorNonBlocking
40
40
  } from './src/developer-utils/consume-iterator.mjs'
41
+ import {
42
+ EASING_PRESETS, createInterruptibleEase, estimateEasingParams, fitEasing, solveBezier
43
+ } from './src/developer-utils/easing-matcher.mjs'
41
44
  import {
42
45
 
43
46
  } from './src/developer-utils/get-type.mjs'
@@ -169,6 +172,7 @@ export * from './src/data-structures/heap.mjs';
169
172
  export * from './src/data-structures/list-node.mjs';
170
173
  export * from './src/developer-utils/binary-search.mjs';
171
174
  export * from './src/developer-utils/consume-iterator.mjs';
175
+ export * from './src/developer-utils/easing-matcher.mjs';
172
176
  export * from './src/developer-utils/get-type.mjs';
173
177
  export * from './src/developer-utils/grid-navigation.mjs';
174
178
  export * from './src/developer-utils/measure-performance.mjs';
@@ -210,5 +214,5 @@ export * from './src/string-arrays/parse-heading.mjs';
210
214
  export * from './src/string-arrays/slugify.mjs';
211
215
 
212
216
  export default [
213
- createLangtonsAnt, createUnlimitedGrid, combinations, combinationsIterator, bin2gray, gray2bin, nChooseK, permutations, permutationsIterator, ConflatingQueue, SetCnt, Stack, Trie, Heap, ListNode, binarySearchArr, binarySearchGE, binarySearchLE, binarySearchRangeIncl, consumeIteratorNonBlocking, detectGridColumns, navigateGrid, measurePerformance, memoize, naturalSearch, QUORUM_CONFIG, QUORUM_DEFAULT_K, QUORUM_DEFAULT_N, QUORUM_MAX_RELATIVE_ERROR, QUORUM_MAX_RELATIVE_ERROR_PCT, QUORUM_ZERO_FLOOR_EPSILON, computeRelativeError, evaluateQuorum, evaluateQuorum4of5, isQuorumEqual, randNormal, array2range, range2array, setSafeInterval, Type, Types, is, isCallable, of, same, barycentricCoordinates, applyScreenDragQuat, axisAngleToMatrix4, axisAngleToQuat, crossProduct, dotProduct, eulerYXZToQuat, getRotationMatrixFromVectors, matToEulerYXZ, matToQuat, multiplyMatrix4, multiplyQuat, normalize, normalizeQuat, projectToTrackball, quatToMat, rotateVecByQuat, slerp, matrixAsArray, OrthoSet, containsPoint1D, containsPointNd, diffBox, diffRange, formatRange, measureBox, measureRange, mutualBox, mutualRange, parseRange, range, sharedMeasureNd, sumBox, sumRange, xorBox, xorRange, egcd, factors, factorsBI, gcd, gcdBI, lcm, lcmBI, lucasLehmerBI, mobius, mobiusBI, mod, modBI, phi, phiBI, powMod, powModBI, tonelliShanksBI, nelderMead, particleSwarmOptimization, simulatedAnnealing, goldenRatio, goldenRatioBI, goldenRatioStr, gpn, gpnBI, heronsFormula, heronsFormulaBI, squareRoot, squareRootBI, arrayHistogram, fromBase64, fromBase64Url, toBase64, toBase64Url, bijective2num, bijective2numBI, num2bijective, num2bijectiveBI, chunks, chunksAsyncIterator, chunksIterator, copyCase, formatBigNumber, formatBigNumberBI, wrapFn, parseHeading, slugify
217
+ createLangtonsAnt, createUnlimitedGrid, combinations, combinationsIterator, bin2gray, gray2bin, nChooseK, permutations, permutationsIterator, ConflatingQueue, SetCnt, Stack, Trie, Heap, ListNode, binarySearchArr, binarySearchGE, binarySearchLE, binarySearchRangeIncl, consumeIteratorNonBlocking, EASING_PRESETS, createInterruptibleEase, estimateEasingParams, fitEasing, solveBezier, detectGridColumns, navigateGrid, measurePerformance, memoize, naturalSearch, QUORUM_CONFIG, QUORUM_DEFAULT_K, QUORUM_DEFAULT_N, QUORUM_MAX_RELATIVE_ERROR, QUORUM_MAX_RELATIVE_ERROR_PCT, QUORUM_ZERO_FLOOR_EPSILON, computeRelativeError, evaluateQuorum, evaluateQuorum4of5, isQuorumEqual, randNormal, array2range, range2array, setSafeInterval, Type, Types, is, isCallable, of, same, barycentricCoordinates, applyScreenDragQuat, axisAngleToMatrix4, axisAngleToQuat, crossProduct, dotProduct, eulerYXZToQuat, getRotationMatrixFromVectors, matToEulerYXZ, matToQuat, multiplyMatrix4, multiplyQuat, normalize, normalizeQuat, projectToTrackball, quatToMat, rotateVecByQuat, slerp, matrixAsArray, OrthoSet, containsPoint1D, containsPointNd, diffBox, diffRange, formatRange, measureBox, measureRange, mutualBox, mutualRange, parseRange, range, sharedMeasureNd, sumBox, sumRange, xorBox, xorRange, egcd, factors, factorsBI, gcd, gcdBI, lcm, lcmBI, lucasLehmerBI, mobius, mobiusBI, mod, modBI, phi, phiBI, powMod, powModBI, tonelliShanksBI, nelderMead, particleSwarmOptimization, simulatedAnnealing, goldenRatio, goldenRatioBI, goldenRatioStr, gpn, gpnBI, heronsFormula, heronsFormulaBI, squareRoot, squareRootBI, arrayHistogram, fromBase64, fromBase64Url, toBase64, toBase64Url, bijective2num, bijective2numBI, num2bijective, num2bijectiveBI, chunks, chunksAsyncIterator, chunksIterator, copyCase, formatBigNumber, formatBigNumberBI, wrapFn, parseHeading, slugify
214
218
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gkucmierz/utils",
3
- "version": "5.4.0",
3
+ "version": "5.4.2",
4
4
  "type": "module",
5
5
  "description": "Usefull functions for solving programming tasks",
6
6
  "keywords": [
@@ -70,6 +70,7 @@
70
70
  "test": "jasmine",
71
71
  "watch": "nodemon --exec 'npm test'",
72
72
  "docs": "npm run docs:build; npm run docs:open",
73
+ "build": "npm run build:main",
73
74
  "build:main": "node scripts/generate_main.mjs && node scripts/generate_instacode_links.mjs",
74
75
  "docs:build": "jsdoc -c jsdoc.json && cp -r docs/@gkucmierz/utils/*/* docs/ && rm -rf docs/@gkucmierz",
75
76
  "docs:open": "open docs/index.html",
@@ -83,6 +84,9 @@
83
84
  "homepage": "https://gitea.7u.pl/gkucmierz/utils",
84
85
  "author": "Grzegorz Kućmierz",
85
86
  "license": "MIT",
87
+ "publishConfig": {
88
+ "access": "public"
89
+ },
86
90
  "devDependencies": {
87
91
  "@eslint/js": "^10.0.1",
88
92
  "docdash": "^2.0.2",
@@ -0,0 +1,227 @@
1
+ import { is } from './type.mjs';
2
+
3
+ const normalizeEasingSamples = samples => {
4
+ if (!is(samples, Array) || samples.length < 2) return null;
5
+ const first = samples[0];
6
+ if (!first) return null;
7
+
8
+ // Cumulative { t, y } mode when neither dt/diffT nor dy/diff are present
9
+ const isCumulative =
10
+ first.t !== undefined &&
11
+ first.y !== undefined &&
12
+ first.dt === undefined &&
13
+ first.diffT === undefined &&
14
+ first.dy === undefined &&
15
+ first.diff === undefined;
16
+
17
+ if (isCumulative) {
18
+ const t0 = samples[0].t < 0 ? samples[0].t : 0;
19
+ const y0 = samples[0].y < 0 ? samples[0].y : 0;
20
+ const spanT = samples[samples.length - 1].t - t0;
21
+ const spanY = samples[samples.length - 1].y - y0;
22
+ if (!spanT || !spanY) return null;
23
+ return samples.map(s => ({
24
+ t: (s.t - t0) / spanT,
25
+ y: (s.y - y0) / spanY
26
+ }));
27
+ }
28
+
29
+ let sumT = 0;
30
+ let sumY = 0;
31
+ const pts = samples.map(s => {
32
+ const dt = s.dt !== undefined ? s.dt : (s.diffT || 0);
33
+ const dy = s.dy !== undefined ? s.dy : (s.diff || 0);
34
+ sumT += dt;
35
+ sumY += dy;
36
+ return { t: sumT, y: sumY };
37
+ });
38
+ if (!sumT || !sumY) return null;
39
+ return pts.map(p => ({ t: p.t / sumT, y: p.y / sumY }));
40
+ };
41
+
42
+ /**
43
+ * Solves Cubic Bezier curve for given control points using Newton-Raphson iteration
44
+ * with bisection fallback for steep/flat segments.
45
+ * @param {number} p1x
46
+ * @param {number} p1y
47
+ * @param {number} p2x
48
+ * @param {number} p2y
49
+ * @returns {function(number): number}
50
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IM5QDYDcCmAhHALxBxgQF8EAzGKMBAIgAEBzAawFcBjMEmQgPQc4IDKgYBuYMHTZ8RPlKA|▶ Try it live in Instacode}
51
+ */
52
+ export const solveBezier = (p1x, p1y, p2x, p2y) => t => {
53
+ if (t <= 0) return 0;
54
+ if (t >= 1) return 1;
55
+ if (p1x === p1y && p2x === p2y) return t;
56
+
57
+ const cx = 3 * p1x;
58
+ const bx = 3 * (p2x - p1x) - cx;
59
+ const ax = 1 - cx - bx;
60
+ const cy = 3 * p1y;
61
+ const by = 3 * (p2y - p1y) - cy;
62
+ const ay = 1 - cy - by;
63
+
64
+ const sampleX = u => ((ax * u + bx) * u + cx) * u;
65
+ const sampleDerivX = u => (3 * ax * u + 2 * bx) * u + cx;
66
+ const sampleY = u => ((ay * u + by) * u + cy) * u;
67
+
68
+ let u = t;
69
+ for (let i = 0; i < 8; i++) {
70
+ const xErr = sampleX(u) - t;
71
+ if (Math.abs(xErr) < 1e-6) return sampleY(u);
72
+ const d = sampleDerivX(u);
73
+ if (Math.abs(d) < 1e-6) break;
74
+ u -= xErr / d;
75
+ }
76
+
77
+ // Bisection fallback when derivative is near zero or Newton steps out of [0, 1]
78
+ let lo = 0;
79
+ let hi = 1;
80
+ u = Math.max(0, Math.min(1, t));
81
+ for (let i = 0; i < 12; i++) {
82
+ const x = sampleX(u);
83
+ if (Math.abs(x - t) < 1e-6) break;
84
+ if (t > x) lo = u;
85
+ else hi = u;
86
+ u = 0.5 * (lo + hi);
87
+ }
88
+ return sampleY(u);
89
+ };
90
+
91
+ /**
92
+ * Standard CSS easing presets and Chromium quintic deceleration curve.
93
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IKIEEDKBJAcgcQH0AFAJVU1QBVMEBfBAMxijAQCIABAcwGsBXAMZgQAUxgAvAPT84IADYBndgG5gwDDgIlylGmqA|▶ Try it live in Instacode}
94
+ */
95
+ export const EASING_PRESETS = Object.freeze({
96
+ 'ease-in-out': solveBezier(0.42, 0, 0.58, 1.0),
97
+ 'ease-out': solveBezier(0, 0, 0.58, 1.0),
98
+ 'ease': solveBezier(0.25, 0.1, 0.25, 1.0),
99
+ 'linear': t => t,
100
+ 'ease-in': solveBezier(0.42, 0, 1.0, 1.0),
101
+ 'quint-out': t => 1 - Math.pow(1 - t, 5),
102
+ });
103
+
104
+ /**
105
+ * Matches telemetry samples against standard CSS easing presets.
106
+ * Supports both frame delta samples ({ dy, dt } / { diff, diffT }) and cumulative points ({ t, y }).
107
+ * Returns sorted list of preset matches with RMSE and percentage score.
108
+ * @param {Array<Object>} samples
109
+ * @returns {Array<{ name: string, match: number, rmse: number }>}
110
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IGYjgUQIYGcQB2A5ggL5oxRgIBEAAkQNYCuAxmCAKYwBeA9MzggANrhoBuYMHRY8hIpKA|▶ Try it live in Instacode}
111
+ */
112
+ export function fitEasing(samples) {
113
+ const norm = normalizeEasingSamples(samples);
114
+ if (!norm) return [];
115
+
116
+ return Object.entries(EASING_PRESETS).map(([name, fn]) => {
117
+ const err = norm.reduce((acc, p) => acc + Math.pow(p.y - fn(p.t), 2), 0);
118
+ const rmse = Math.sqrt(err / norm.length);
119
+ const match = Math.max(0, Math.min(100, Math.round((1 - rmse * 2) * 1000) / 10));
120
+ return { name, match, rmse: Math.round(rmse * 1000) / 1000 };
121
+ }).sort((a, b) => b.match - a.match || a.rmse - b.rmse);
122
+ }
123
+
124
+ /**
125
+ * Estimates optimal cubic-bezier control points and asymmetry metrics for raw telemetry samples.
126
+ * Uses a 2-stage coarse-to-fine search over (x1, y1, x2, y2) in [0, 1]^4.
127
+ * @param {Array<Object>} samples
128
+ * @returns {?{ asymmetryRatio: number, midpointT: number, cssBezier: string, controlPoints: Array<number>, matchPercentage: number, evaluate: function(number): number }}
129
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IKYGc7gIZ1QUW3RADsBzABWxmzHQQF8EAzGKMBAIgAEyBrAK4BjMCFQwAXgHoBWADbpOAbmDAMWMLgJFSlarXQqgA|▶ Try it live in Instacode}
130
+ */
131
+ export function estimateEasingParams(samples) {
132
+ if (!is(samples, Array) || samples.length < 3) return null;
133
+ const norm = normalizeEasingSamples(samples);
134
+ if (!norm) return null;
135
+
136
+ const midPoint = norm.find(p => p.y >= 0.5) || { t: 0.5 };
137
+ const asymmetry = Math.round(((1 - midPoint.t) / (midPoint.t || 1e-4)) * 10) / 10;
138
+
139
+ const evalRmse = (x1, y1, x2, y2) => {
140
+ const fn = solveBezier(x1, y1, x2, y2);
141
+ let err = 0;
142
+ for (let i = 0; i < norm.length; i++) {
143
+ const d = norm[i].y - fn(norm[i].t);
144
+ err += d * d;
145
+ }
146
+ return Math.sqrt(err / norm.length);
147
+ };
148
+
149
+ let best = { rmse: evalRmse(0.25, 0.1, 0.25, 1.0), x1: 0.25, y1: 0.1, x2: 0.25, y2: 1.0 };
150
+
151
+ // Seed with known CSS presets first
152
+ const presetSeeds = [
153
+ [0.25, 0.1, 0.25, 1.0],
154
+ [0.42, 0.0, 0.58, 1.0],
155
+ [0.0, 0.0, 0.58, 1.0],
156
+ [0.42, 0.0, 1.0, 1.0],
157
+ [0.25, 0.25, 0.75, 0.75],
158
+ [0.22, 1.0, 0.36, 1.0]
159
+ ];
160
+ for (const [x1, y1, x2, y2] of presetSeeds) {
161
+ const rmse = evalRmse(x1, y1, x2, y2);
162
+ if (rmse < best.rmse) best = { rmse, x1, y1, x2, y2 };
163
+ }
164
+
165
+ // Stage 1: Coarse grid over (x1, y1, x2, y2)
166
+ for (let x1 = 0.05; x1 <= 0.65; x1 += 0.15) {
167
+ for (let y1 = 0.0; y1 <= 1.0; y1 += 0.2) {
168
+ for (let x2 = 0.15; x2 <= 0.95; x2 += 0.2) {
169
+ for (let y2 = 0.6; y2 <= 1.0; y2 += 0.2) {
170
+ const rmse = evalRmse(x1, y1, x2, y2);
171
+ if (rmse < best.rmse) best = { rmse, x1, y1, x2, y2 };
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ // Stage 2: Local coordinate refinement around best candidate
178
+ for (const step of [0.05, 0.02]) {
179
+ let improved = true;
180
+ while (improved) {
181
+ improved = false;
182
+ const coords = ['x1', 'y1', 'x2', 'y2'];
183
+ for (const key of coords) {
184
+ for (const dir of [-step, step]) {
185
+ const cand = { ...best, [key]: Math.max(0, Math.min(1, best[key] + dir)) };
186
+ const rmse = evalRmse(cand.x1, cand.y1, cand.x2, cand.y2);
187
+ if (rmse + 1e-6 < best.rmse) {
188
+ best = { rmse, x1: cand.x1, y1: cand.y1, x2: cand.x2, y2: cand.y2 };
189
+ improved = true;
190
+ }
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ const rx1 = Number(best.x1.toFixed(2));
197
+ const ry1 = Number(best.y1.toFixed(2));
198
+ const rx2 = Number(best.x2.toFixed(2));
199
+ const ry2 = Number(best.y2.toFixed(2));
200
+ const match = Math.max(0, Math.min(100, Math.round((1 - best.rmse * 2) * 1000) / 10));
201
+ const cssBezier = `cubic-bezier(${rx1.toFixed(2)}, ${ry1.toFixed(2)}, ${rx2.toFixed(2)}, ${ry2.toFixed(2)})`;
202
+
203
+ return {
204
+ asymmetryRatio: asymmetry,
205
+ midpointT: Math.round(midPoint.t * 100) / 100,
206
+ cssBezier,
207
+ controlPoints: [rx1, ry1, rx2, ry2],
208
+ matchPercentage: match,
209
+ evaluate: solveBezier(rx1, ry1, rx2, ry2)
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Creates interruptible easing curve preserving kinetic momentum (C1 continuous).
215
+ * @param {number} vCurr Current velocity in pixels/second
216
+ * @param {number} targetDist Remaining distance to target in pixels
217
+ * @param {number} duration Total duration in milliseconds
218
+ * @returns {function(number): number}
219
+ * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IMYwKYEM4YJIB2uMMArhHCAEYA2GAolgM4YIC+CAZjFGAgEQABAOYBrUqjAgMMAF4B6UpRpN+AbmDB02XIWJkK1OoxYagA|▶ Try it live in Instacode}
220
+ */
221
+ export function createInterruptibleEase(vCurr, targetDist, duration) {
222
+ if (!targetDist || duration <= 0) return t => t;
223
+ const slope = (vCurr * (duration / 1000)) / targetDist;
224
+ const p1x = 0.25;
225
+ const p1y = Math.min(1.0, Math.max(0, slope * p1x));
226
+ return solveBezier(p1x, p1y, 0.25, 1.0);
227
+ }
@@ -2,9 +2,65 @@
2
2
  * @module developer-utils
3
3
  */
4
4
 
5
+ /**
6
+ * Builds a 2D occupancy matrix `rows[r][c] = itemIndex` (or `-1` for empty gap)
7
+ * when `spanLastRow` or `colSpans` is enabled.
8
+ *
9
+ * @param {number} totalItems
10
+ * @param {number} cols
11
+ * @param {Object} opts
12
+ * @returns {{ matrix: number[][], itemPos: Array<{ row: number, cols: number[] }> }}
13
+ */
14
+ const buildSpannedGridMatrix = (totalItems, cols, opts = {}) => {
15
+ const spanLastRow = Boolean(opts.spanLastRow);
16
+ const colSpans = opts.colSpans || null;
17
+ const matrix = [];
18
+ const itemPos = Array.from({ length: totalItems }, () => ({ row: 0, cols: [] }));
19
+
20
+ let curRow = new Array(cols).fill(-1);
21
+ let c = 0;
22
+
23
+ for (let i = 0; i < totalItems; i += 1) {
24
+ let span = 1;
25
+ if (colSpans) {
26
+ const rawSpan = Array.isArray(colSpans) ? colSpans[i] : colSpans[i];
27
+ if (Number.isFinite(Number(rawSpan)) && Number(rawSpan) >= 1) {
28
+ span = Math.min(cols, Math.floor(Number(rawSpan)));
29
+ }
30
+ }
31
+ if (c + span > cols) {
32
+ matrix.push(curRow);
33
+ curRow = new Array(cols).fill(-1);
34
+ c = 0;
35
+ }
36
+ if (spanLastRow && i === totalItems - 1 && c < cols) {
37
+ span = cols - c;
38
+ }
39
+ const rowIdx = matrix.length;
40
+ itemPos[i].row = rowIdx;
41
+ for (let k = 0; k < span && c < cols; k += 1) {
42
+ curRow[c] = i;
43
+ itemPos[i].cols.push(c);
44
+ c += 1;
45
+ }
46
+ if (c >= cols) {
47
+ matrix.push(curRow);
48
+ curRow = new Array(cols).fill(-1);
49
+ c = 0;
50
+ }
51
+ }
52
+
53
+ if (c > 0) {
54
+ matrix.push(curRow);
55
+ }
56
+
57
+ return { matrix, itemPos };
58
+ };
59
+
5
60
  /**
6
61
  * Resolves the next 0-based index when navigating a 2D grid (matrix) that may contain
7
- * trailing gaps in its last row (`totalItems` not divisible by `columns`).
62
+ * trailing gaps in its last row (`totalItems` not divisible by `columns`) or spanned cells
63
+ * (`spanLastRow: true` / `colSpans` / `preferredCol`).
8
64
  *
9
65
  * Pure mathematical/index function — completely independent of the DOM.
10
66
  *
@@ -13,6 +69,9 @@
13
69
  * - `wrapHoriz` / `wrapHorizontal` (boolean, optional): overrides `wrap` for horizontal movement (`left` / `right`).
14
70
  * - `wrapVert` / `wrapVertical` (boolean, optional): overrides `wrap` for vertical movement (`up` / `down`).
15
71
  * - `rowStrict` (boolean, default `false`): when `true`, horizontal movement wraps/clamps strictly within the current row instead of reading order.
72
+ * - `spanLastRow` (boolean, default `false`): when `true`, the final item (`totalItems - 1`) spans all remaining columns in the last row (e.g. a 2x3 grid where row 2 has a full-width card `[4, 4]`).
73
+ * - `colSpans` (number[]|Object, optional): explicit column span per item index (`>= 1`).
74
+ * - `preferredCol` (number, optional): preferred column index (`0 <= preferredCol < columns`) preserved when stepping vertically out of a multi-column spanned item.
16
75
  *
17
76
  * When `columns === 1` (single-column layout), `right` behaves identically to `down`
18
77
  * and `left` behaves identically to `up`.
@@ -60,6 +119,7 @@ export const navigateGrid = (
60
119
  ? opts.wrapVert
61
120
  : baseWrap;
62
121
  const rowStrict = Boolean(opts.rowStrict);
122
+ const hasSpans = Boolean(opts.spanLastRow || opts.colSpans);
63
123
 
64
124
  const row = Math.floor(cur / cols);
65
125
  const col = cur % cols;
@@ -91,6 +151,26 @@ export const navigateGrid = (
91
151
  // Horizontal movement (left / right)
92
152
  if (deltaH !== 0) {
93
153
  if (rowStrict) {
154
+ if (hasSpans && cols > 1) {
155
+ const { matrix, itemPos } = buildSpannedGridMatrix(totalItems, cols, opts);
156
+ const curRowIdx = itemPos[cur]?.row ?? 0;
157
+ const rowItems = [];
158
+ for (const cellItem of matrix[curRowIdx] || []) {
159
+ if (cellItem >= 0 && !rowItems.includes(cellItem)) {
160
+ rowItems.push(cellItem);
161
+ }
162
+ }
163
+ const posInRow = rowItems.indexOf(cur);
164
+ if (posInRow < 0 || rowItems.length <= 0) return cur;
165
+ const nextPos = posInRow + deltaH;
166
+ if (nextPos >= 0 && nextPos < rowItems.length) {
167
+ return rowItems[nextPos];
168
+ }
169
+ if (wrapHoriz) {
170
+ return rowItems[((nextPos % rowItems.length) + rowItems.length) % rowItems.length];
171
+ }
172
+ return cur;
173
+ }
94
174
  const rowStart = row * cols;
95
175
  const rowLen = Math.min(cols, totalItems - rowStart);
96
176
  if (rowLen <= 0) return cur;
@@ -114,6 +194,42 @@ export const navigateGrid = (
114
194
  return cur;
115
195
  }
116
196
 
197
+ // Vertical movement (up / down) when spanned cells are configured
198
+ if (hasSpans && cols > 1) {
199
+ const { matrix, itemPos } = buildSpannedGridMatrix(totalItems, cols, opts);
200
+ const curInfo = itemPos[cur] || { row: 0, cols: [0] };
201
+ const occupiedCols = curInfo.cols.length > 0 ? curInfo.cols : [0];
202
+ let targetCol = occupiedCols[0];
203
+ if (occupiedCols.length > 1 && Number.isFinite(Number(opts.preferredCol))) {
204
+ const pref = Math.max(0, Math.min(cols - 1, Math.floor(Number(opts.preferredCol))));
205
+ if (occupiedCols.includes(pref)) {
206
+ targetCol = pref;
207
+ }
208
+ }
209
+
210
+ const validRowsForCol = [];
211
+ for (let r = 0; r < matrix.length; r += 1) {
212
+ if (matrix[r][targetCol] >= 0) {
213
+ validRowsForCol.push(r);
214
+ }
215
+ }
216
+ if (validRowsForCol.length === 0) return cur;
217
+
218
+ const curRowPos = validRowsForCol.indexOf(curInfo.row);
219
+ const baseRowPos = curRowPos >= 0 ? curRowPos : 0;
220
+ const nextRowPos = baseRowPos + deltaV;
221
+ if (nextRowPos >= 0 && nextRowPos < validRowsForCol.length) {
222
+ return matrix[validRowsForCol[nextRowPos]][targetCol];
223
+ }
224
+ if (wrapVert) {
225
+ const wrappedPos =
226
+ ((nextRowPos % validRowsForCol.length) + validRowsForCol.length) %
227
+ validRowsForCol.length;
228
+ return matrix[validRowsForCol[wrappedPos]][targetCol];
229
+ }
230
+ return cur;
231
+ }
232
+
117
233
  // Vertical movement (up / down) along column `col` (handling incomplete last row gaps)
118
234
  const colRows = col < totalItems ? Math.floor((totalItems - 1 - col) / cols) + 1 : 0;
119
235
  if (colRows <= 0) return cur;
@@ -159,7 +159,7 @@ const computeClusterConsensusValue = (values) => {
159
159
  * - `success: true` is returned **if and only if** the largest mutually-equal clique $C^*$ has size $|C^*| \ge K$
160
160
  * (e.g., at least $4$ out of $5$ datapoints are mutually equal within $0.01\%$).
161
161
  *
162
- * @param {Array<number|number[]|{source?: string, id?: string, value: number|number[]}>} datapoints - Input datapoints.
162
+ * @param {Array<(number|Array<number>|Object)>} datapoints - Input datapoints.
163
163
  * @param {Object} [options={}] - Quorum configuration options.
164
164
  * @param {number} [options.k=4] - Minimum number of mutually-equal datapoints required for success ($K$).
165
165
  * @param {number} [options.n=5] - Expected total number of independent sources ($N$).
@@ -332,7 +332,7 @@ export const evaluateQuorum = (
332
332
  * Convenience helper for the standard $4\text{-of-}5$ quorum check with $0.01\%$ (`0.0001`)
333
333
  * maximum relative error tolerance.
334
334
  *
335
- * @param {Array<number|number[]|{source?: string, id?: string, value: number|number[]}>} datapoints - 5 source datapoints.
335
+ * @param {Array<(number|Array<number>|Object)>} datapoints - 5 source datapoints.
336
336
  * @param {Object} [options={}] - Optional overrides (`maxRelativeError`, `strictN`, `zeroFloor`).
337
337
  * @returns {Object} Quorum evaluation result.
338
338
  * @see {@link https://instacode.app/run/FASwtgDg9gTgLgAgN4IKYDcCGAbArpuVARV1lzABYoAzAVgQF8FqYowEAiAAQHMBrXAGMwIVDABeAelxwQ2AM4cA3MGAYc+QiTKUatFUA|▶ Try it live in Instacode}