@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.
|
|
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
|
|
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
|
|
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}
|