@pi-archimedes/core 2.5.0 → 2.6.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/README.md +34 -25
- package/package.json +3 -3
- package/src/chrome.test.ts +53 -0
- package/src/chrome.ts +2 -0
- package/src/config.test.ts +29 -0
- package/src/config.ts +28 -0
- package/src/editor/index.test.ts +619 -0
- package/src/editor/index.ts +134 -2
- package/src/editor/spin.test.ts +702 -0
- package/src/editor/spin.ts +471 -0
- package/src/index.test.ts +362 -0
- package/src/index.ts +91 -5
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { SpinnerStyle } from "../config.js";
|
|
3
|
+
|
|
4
|
+
/** Growing 2×4 braille dot block, Unicode braille-chart dot order (U+2801 → U+28FF) — its 8 stages run through the 4-cell window in 2-step line pairs (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿). */
|
|
5
|
+
const STAGE_BRAILLE = ["⠁", "⠉", "⠋", "⠛", "⠟", "⠿", "⡿", "⣿"];
|
|
6
|
+
/** Width-1 shading fallback for EAW terminals (⣿ reports visible width 2) — the same 8 stages. */
|
|
7
|
+
const STAGE_SHADE = ["░", "░", "░", "░", "▒", "▒", "▓", "█"];
|
|
8
|
+
|
|
9
|
+
/** Native per-tick tempo (ms) per gallery style, taken from the source library. The 32 ms tick floor (and the `editorSpinSpeed` × mult) is applied at the editor / editor factory — not here. (The 32 ms floor also caps a 30 ms native style under `fast` at 32.) */
|
|
10
|
+
export const SPIN_INTERVALS: Record<SpinnerStyle, number> = {
|
|
11
|
+
typing: 80,
|
|
12
|
+
pulse: 60,
|
|
13
|
+
rain: 40,
|
|
14
|
+
cascade: 40,
|
|
15
|
+
columns: 40,
|
|
16
|
+
"wave-rows": 40,
|
|
17
|
+
"diagonal-swipe": 30,
|
|
18
|
+
sparkle: 40,
|
|
19
|
+
pendulum: 12,
|
|
20
|
+
marquee: 55,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The 8 chart-order dot masks of the 2×4 braille block; char k = `String.fromCharCode(0x2800 + mask)` MUST exactly reproduce the STAGE_BRAILLE / STAGE_SHADE chars (⠁ ⠉ ⠋ ⠛ ⠟ ⠿ ⡿ ⣿). */
|
|
24
|
+
export const STAGE_MASKS = [0x01, 0x09, 0x0B, 0x1B, 0x1F, 0x3F, 0x7F, 0xFF];
|
|
25
|
+
|
|
26
|
+
/** The per-row dot bits of one braille cell (8-bit mask), by dot column — row 0: L 0x01 / R 0x08, row 1: 0x02 / 0x10, row 2: 0x04 / 0x20, row 3: 0x40 / 0x80. Shared by the 1×4 grid ports in `SPIN_VARIANTS` (dot column = pc % 2). */
|
|
27
|
+
const DOT_BITS = [
|
|
28
|
+
[0x01, 0x08] as const,
|
|
29
|
+
[0x02, 0x10] as const,
|
|
30
|
+
[0x04, 0x20] as const,
|
|
31
|
+
[0x40, 0x80] as const,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** The source's `seededRandom` LCG helper, verbatim (`s = (s * 1664525 + 1013904223) & 0xffffffff`) — the gallery precompute-context seeds: 42 → importance, 19 → shuffled/target, 123 → colRandom. Only colRandom is needed by the 1×4 ports (rain). */
|
|
35
|
+
function seededRandom(seed: number): () => number {
|
|
36
|
+
let s = seed;
|
|
37
|
+
return () => {
|
|
38
|
+
s = (s * 1664525 + 1013904223) & 0xffffffff;
|
|
39
|
+
return (s >>> 0) / 0xffffffff;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The source's `clamp` helper, verbatim. */
|
|
44
|
+
function clamp(value: number, min: number, max: number): number {
|
|
45
|
+
return Math.min(max, Math.max(min, value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The 1×4 grid's colRandom — built once at module scope (the source's `contextCache` pattern) with seed 123 and the same draw order as the source's `getPrecomputeContext` for width 4 × height 4: 8 draws, pc 0..7. */
|
|
49
|
+
const RAIN_COL_RANDOM: number[] = (() => {
|
|
50
|
+
const rand = seededRandom(123);
|
|
51
|
+
return Array.from({ length: 8 }, () => rand());
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
/** One gallery-derived spin style: `steps` (tick cycle incl. hold — typing: 32 + 6 = 38; a hold-0 port style walks the table over all `steps`), `hold` (the clamp tail after the fill walk), and `compute(step)`, which returns the 4 braille dot-masks (8-bit each) for the 4-cell window at `step` (0-based: a hold-0 style's step 0 is its real first frame; for hold > 0, 0 is the blank beat and beyond `steps - hold` the frames clamp to the last one). */
|
|
55
|
+
export interface SpinStyleConfig {
|
|
56
|
+
steps: number;
|
|
57
|
+
hold: number;
|
|
58
|
+
compute: (step: number) => number[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The gallery-derived variants — the full ten-style set (all registered); an unregistered / unknown name normalizes to typing (`normalizeSpinnerStyle`). */
|
|
62
|
+
export const SPIN_VARIANTS: Partial<Record<SpinnerStyle, SpinStyleConfig>> = {
|
|
63
|
+
typing: {
|
|
64
|
+
steps: 38,
|
|
65
|
+
hold: 6,
|
|
66
|
+
/** The exact current cell-by-cell math: line = floor(step/8), pair = floor((step%8)/2), s = step%2; cells: stage(2k+1) for j<i, stage(2k+s) for j=i, the previous line's full stage (or blank on line 1, where "not yet reached" is the 0-mask = the clear) for j>i. */
|
|
67
|
+
compute(step: number): number[] {
|
|
68
|
+
const k = Math.floor(step / 8);
|
|
69
|
+
const i = Math.floor((step % 8) / 2);
|
|
70
|
+
const s = step % 2;
|
|
71
|
+
const cells = [0, 0, 0, 0];
|
|
72
|
+
for (let j = 0; j < 4; j++) {
|
|
73
|
+
if (j < i) cells[j] = STAGE_MASKS[2 * k + 1]!;
|
|
74
|
+
else if (j === i) cells[j] = STAGE_MASKS[2 * k + s]!;
|
|
75
|
+
else cells[j] = k === 0 ? 0 : STAGE_MASKS[2 * k - 1]!;
|
|
76
|
+
}
|
|
77
|
+
return cells;
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
// ── Batch 2: ported from the shadcn braille-loader gallery, 1×4 (8 dot-columns × 4 rows) adaptation — formulas and constants unchanged from the source (source width 4 = 4 cells = 8 dot-columns, height 4). Each `compute` returns the 4 braille cell masks (8-bit each). ──
|
|
81
|
+
/** A vertical band riding a phase-offset sine wave in each of the 8 dot-columns — 4 cells, equalizer look. */
|
|
82
|
+
"wave-rows": {
|
|
83
|
+
steps: 20,
|
|
84
|
+
hold: 0,
|
|
85
|
+
compute(step: number): number[] {
|
|
86
|
+
const progress = step / 20;
|
|
87
|
+
const basePhase = progress * Math.PI * 2;
|
|
88
|
+
const colPhaseStep = (Math.PI * 2) / Math.max(2, 8);
|
|
89
|
+
const bandWidth = 0.9;
|
|
90
|
+
const cells = [0, 0, 0, 0];
|
|
91
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
92
|
+
const colWave = Math.sin(basePhase + pc * colPhaseStep);
|
|
93
|
+
const centerRow = ((colWave + 1) / 2) * 3;
|
|
94
|
+
for (let row = 0; row < 4; row++) {
|
|
95
|
+
if (Math.abs(row - centerRow) <= bandWidth) {
|
|
96
|
+
cells[Math.floor(pc / 2)]! |= DOT_BITS[row]![pc % 2]!;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return cells;
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
/** The 4 sequential bottom-up column fills. */
|
|
104
|
+
columns: {
|
|
105
|
+
steps: 48,
|
|
106
|
+
hold: 0,
|
|
107
|
+
compute(step: number): number[] {
|
|
108
|
+
const stepsPerColumn = 4 + 1; // height + 1
|
|
109
|
+
const totalSteps = 8 * stepsPerColumn;
|
|
110
|
+
const s = Math.floor((step / 48) * totalSteps) % totalSteps;
|
|
111
|
+
const activePc = Math.floor(s / stepsPerColumn);
|
|
112
|
+
const activeFill = s % stepsPerColumn;
|
|
113
|
+
const cells = [0, 0, 0, 0];
|
|
114
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
115
|
+
const ci = Math.floor(pc / 2);
|
|
116
|
+
const dc = pc % 2;
|
|
117
|
+
if (pc < activePc) {
|
|
118
|
+
for (let row = 0; row < 4; row++) cells[ci]! |= DOT_BITS[row]![dc]!;
|
|
119
|
+
} else if (pc === activePc) {
|
|
120
|
+
const fill = Math.max(0, Math.min(4, activeFill));
|
|
121
|
+
for (let i = 0; i < fill; i++) cells[ci]! |= DOT_BITS[3 - i]![dc]!;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return cells;
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
/** A ring expanding and contracting around the center of the 4 cells. */
|
|
128
|
+
pulse: {
|
|
129
|
+
steps: 23,
|
|
130
|
+
hold: 0,
|
|
131
|
+
compute(step: number): number[] {
|
|
132
|
+
const period = 900;
|
|
133
|
+
const t = step * 40;
|
|
134
|
+
const scale = 1 + 0.06 * Math.sin((2 * Math.PI * t) / period);
|
|
135
|
+
const centerX = (8 - 1) / 2; // (width · 2 − 1) / 2, width 4
|
|
136
|
+
const centerY = (4 - 1) / 2;
|
|
137
|
+
const maxDist = Math.sqrt(centerX * centerX + centerY * centerY);
|
|
138
|
+
const ringWidth = 0.8;
|
|
139
|
+
const ringPos =
|
|
140
|
+
((Math.sin(((2 * Math.PI * t) / period) * 2) + 1) / 2) * maxDist;
|
|
141
|
+
const cells = [0, 0, 0, 0];
|
|
142
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
143
|
+
for (let row = 0; row < 4; row++) {
|
|
144
|
+
const dx = (pc - centerX) / scale;
|
|
145
|
+
const dy = (row - centerY) / scale;
|
|
146
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
147
|
+
if (Math.abs(dist - ringPos) < ringWidth) {
|
|
148
|
+
cells[Math.floor(pc / 2)]! |= DOT_BITS[row]![pc % 2]!;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return cells;
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
/** Diagonal stripe bands sliding across the 8 dot-columns. */
|
|
156
|
+
marquee: {
|
|
157
|
+
steps: 48,
|
|
158
|
+
hold: 0,
|
|
159
|
+
compute(step: number): number[] {
|
|
160
|
+
const offset = Math.floor((step / 48) * 8);
|
|
161
|
+
const cells = [0, 0, 0, 0];
|
|
162
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
163
|
+
for (let row = 0; row < 4; row++) {
|
|
164
|
+
const stripe = (pc + row + offset) % 4;
|
|
165
|
+
if (stripe < 2) cells[Math.floor(pc / 2)]! |= DOT_BITS[row]![pc % 2]!;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return cells;
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
// ── Batch 3: ported from the shadcn braille-loader gallery, 1×4 (8 dot-columns × 4 rows) adaptation — formulas and constants unchanged from the source (dot column = pc % 2). Each `compute` returns the 4 braille cell masks (8-bit each). ──
|
|
172
|
+
/** 8 vertical slings swinging in a phase-stiffened sine — a pendulum side. */
|
|
173
|
+
pendulum: {
|
|
174
|
+
steps: 120,
|
|
175
|
+
hold: 0,
|
|
176
|
+
compute(step: number): number[] {
|
|
177
|
+
const progress = step / 120;
|
|
178
|
+
// fast natural swing
|
|
179
|
+
const basePhase = progress * Math.PI * 8;
|
|
180
|
+
// dynamic spatial sine (CRITICAL)
|
|
181
|
+
const spread = Math.sin(progress * Math.PI) * 1.1;
|
|
182
|
+
const threshold = 0.7;
|
|
183
|
+
const cells = [0, 0, 0, 0];
|
|
184
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
185
|
+
// sine exists INSIDE braille cell
|
|
186
|
+
const swing = Math.sin(basePhase + pc * spread);
|
|
187
|
+
const center = ((1 - swing) * (4 - 1)) / 2;
|
|
188
|
+
for (let row = 0; row < 4; row++) {
|
|
189
|
+
if (Math.abs(row - center) < threshold) {
|
|
190
|
+
cells[Math.floor(pc / 2)]! |= DOT_BITS[row]![pc % 2]!;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return cells;
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
/** A diagonal light cascading left → right-down through the 4 cells. */
|
|
198
|
+
cascade: {
|
|
199
|
+
steps: 60,
|
|
200
|
+
hold: 0,
|
|
201
|
+
compute(step: number): number[] {
|
|
202
|
+
const progress = step / 60;
|
|
203
|
+
const leadingEdge = progress * 2;
|
|
204
|
+
const cells = [0, 0, 0, 0];
|
|
205
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
206
|
+
const normalizedX = pc / 8;
|
|
207
|
+
for (let row = 0; row < 4; row++) {
|
|
208
|
+
const normalizedY = row / 4;
|
|
209
|
+
const delta = Math.abs(normalizedX + normalizedY - leadingEdge);
|
|
210
|
+
if (delta < 0.2) cells[Math.floor(pc / 2)]! |= DOT_BITS[row]![pc % 2]!;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return cells;
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
/** A diagonal wipe fills, a diagonal wipe clears — their fill/clear phase, 30 steps each. */
|
|
217
|
+
"diagonal-swipe": {
|
|
218
|
+
steps: 60,
|
|
219
|
+
hold: 0,
|
|
220
|
+
compute(step: number): number[] {
|
|
221
|
+
const maxDiag = 8 - 1 + 3; // pixelCols − 1 + (height − 1)
|
|
222
|
+
const cycleFrame = step % 60;
|
|
223
|
+
const clearFrames = Math.max(2, Math.floor(60 / 2));
|
|
224
|
+
const fillFrames = Math.max(2, 60 - clearFrames);
|
|
225
|
+
const clearPhase = cycleFrame < clearFrames;
|
|
226
|
+
const localFrame = clearPhase ? cycleFrame : cycleFrame - clearFrames;
|
|
227
|
+
const localTotal = (clearPhase ? clearFrames : fillFrames) - 1;
|
|
228
|
+
const phaseProgress = localTotal > 0 ? localFrame / localTotal : 1;
|
|
229
|
+
const sweepFront = phaseProgress * (maxDiag + 1);
|
|
230
|
+
const cells = [0, 0, 0, 0];
|
|
231
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
232
|
+
for (let row = 0; row < 4; row++) {
|
|
233
|
+
const diag = pc + row;
|
|
234
|
+
const show = clearPhase ? diag >= sweepFront : diag < sweepFront;
|
|
235
|
+
if (show) cells[Math.floor(pc / 2)]! |= DOT_BITS[row]![pc % 2]!;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return cells;
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
// ── Batch 4: the final 2 gallery ports (rain, sparkle) — same 1×4 (8 dot-columns × 4 rows) adaptation; formulas and constants unchanged from the source (dot column = pc % 2). Each `compute` returns the 4 braille cell masks (8-bit each). ──
|
|
242
|
+
/** Seeded raindrops with gravity, mid-wobble, miss-chance skip and a guaranteed single-dot fallback — survives a 1×4 grid: one 4-row column per each of the 8 dot-columns (2 dot-columns per cell). */
|
|
243
|
+
rain: {
|
|
244
|
+
steps: 90,
|
|
245
|
+
hold: 0,
|
|
246
|
+
compute(step: number): number[] {
|
|
247
|
+
const t = step * 40;
|
|
248
|
+
const cells = [0, 0, 0, 0];
|
|
249
|
+
let activeDrops = 0;
|
|
250
|
+
|
|
251
|
+
for (let pc = 0; pc < 8; pc++) {
|
|
252
|
+
const rand = RAIN_COL_RANDOM[pc]!;
|
|
253
|
+
const period = 1200 + rand * 1000;
|
|
254
|
+
const cyclePos = t / period + rand * 0.91 + pc * 0.07;
|
|
255
|
+
const cycleIndex = Math.floor(cyclePos);
|
|
256
|
+
const phase = cyclePos - cycleIndex;
|
|
257
|
+
|
|
258
|
+
// seeds A/B/C — the source's fixed noise constants
|
|
259
|
+
const seedA = cycleIndex * 173 + pc * 37 + Math.floor(rand * 1009);
|
|
260
|
+
const noiseA = Math.sin(seedA * 12.9898) * 43758.5453;
|
|
261
|
+
const rollA = noiseA - Math.floor(noiseA);
|
|
262
|
+
|
|
263
|
+
const seedB = cycleIndex * 257 + pc * 61 + Math.floor(rand * 881);
|
|
264
|
+
const noiseB = Math.sin(seedB * 78.233) * 12345.6789;
|
|
265
|
+
const rollB = noiseB - Math.floor(noiseB);
|
|
266
|
+
|
|
267
|
+
const seedC = cycleIndex * 97 + pc * 149 + Math.floor(rand * 733);
|
|
268
|
+
const noiseC = Math.sin(seedC * 39.3467) * 31337.4242;
|
|
269
|
+
const rollC = noiseC - Math.floor(noiseC);
|
|
270
|
+
|
|
271
|
+
const missChance = 0.02 + rand * 0.08;
|
|
272
|
+
if (rollA < missChance) continue;
|
|
273
|
+
|
|
274
|
+
const spawnDelay = 0.0 + rollB * 0.2;
|
|
275
|
+
const fallDuration = 0.48 + rollC * 0.42;
|
|
276
|
+
const endPhase = spawnDelay + fallDuration;
|
|
277
|
+
if (phase < spawnDelay || phase > endPhase) continue;
|
|
278
|
+
|
|
279
|
+
const localPhase = (phase - spawnDelay) / fallDuration;
|
|
280
|
+
const gravityCurve = 1.6 + rollC * 1.2;
|
|
281
|
+
const accelerated = Math.pow(localPhase, gravityCurve);
|
|
282
|
+
|
|
283
|
+
const midWeight = Math.max(0, 1 - Math.abs(localPhase - 0.5) * 2);
|
|
284
|
+
const wobbleSeed = cycleIndex * 0.73 + pc * 1.31 + rand * 4.7;
|
|
285
|
+
const midWobble = Math.sin(wobbleSeed + localPhase * Math.PI * 6) * 0.1 * midWeight;
|
|
286
|
+
|
|
287
|
+
const y = Math.floor((accelerated + midWobble) * 5) - 1; // × (height + 1)
|
|
288
|
+
if (y < 0 || y >= 4) continue;
|
|
289
|
+
|
|
290
|
+
cells[Math.floor(pc / 2)]! |= DOT_BITS[y]![pc % 2]!;
|
|
291
|
+
activeDrops++;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (activeDrops === 0) {
|
|
295
|
+
const fallbackPos = (t / 1600) % 1;
|
|
296
|
+
const fallbackPc = Math.floor(fallbackPos * 8) % 8;
|
|
297
|
+
const fallbackPhase = fallbackPos * 5; // × (height + 1)
|
|
298
|
+
const fallbackY = clamp(Math.floor(fallbackPhase), 0, 3);
|
|
299
|
+
cells[Math.floor(fallbackPc / 2)]! |= DOT_BITS[fallbackY]![fallbackPc % 2]!;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return cells;
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
/** Hash shimmer with edge compensation, 2×2 regional competition, a 4-frame lifecycle and a one-frame positional jitter — fully deterministic (verbatim hash: 374761393 / 668265263 / 1442695041, × 1274126177, / 4294967295 in 32-bit ops). */
|
|
306
|
+
sparkle: {
|
|
307
|
+
steps: 60,
|
|
308
|
+
hold: 0,
|
|
309
|
+
compute(step: number): number[] {
|
|
310
|
+
const cells = [0, 0, 0, 0];
|
|
311
|
+
const hash = (x: number, y: number, t: number): number => {
|
|
312
|
+
let n = x * 374761393 + y * 668265263 + t * 1442695041;
|
|
313
|
+
n = (n ^ (n >> 13)) * 1274126177;
|
|
314
|
+
return ((n ^ (n >> 16)) >>> 0) / 4294967295;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const density = 0.095;
|
|
318
|
+
const lifetime = 4;
|
|
319
|
+
const phase = Math.floor(step / 2);
|
|
320
|
+
const regionSize = 2;
|
|
321
|
+
const drawableHeight = 4;
|
|
322
|
+
const pixelCols = 8;
|
|
323
|
+
|
|
324
|
+
for (let row = 0; row < drawableHeight; row++) {
|
|
325
|
+
for (let col = 0; col < pixelCols; col++) {
|
|
326
|
+
let v = hash(col, row, phase);
|
|
327
|
+
// edge compensation (col edge 0/7, row edge 0/3)
|
|
328
|
+
const edgeBias =
|
|
329
|
+
0.12 * ((col === 0 || col === pixelCols - 1 ? 1 : 0) + (row === 0 || row === drawableHeight - 1 ? 1 : 0));
|
|
330
|
+
v -= edgeBias;
|
|
331
|
+
if (v > density) continue;
|
|
332
|
+
|
|
333
|
+
// regional competition (2×2), winner = min v
|
|
334
|
+
const rx = Math.floor(col / regionSize);
|
|
335
|
+
const ry = Math.floor(row / regionSize);
|
|
336
|
+
let winner = true;
|
|
337
|
+
for (let oy = 0; oy < regionSize && winner; oy++) {
|
|
338
|
+
for (let ox = 0; ox < regionSize; ox++) {
|
|
339
|
+
const nx = rx * regionSize + ox;
|
|
340
|
+
const ny = ry * regionSize + oy;
|
|
341
|
+
if (nx === col && ny === row) continue;
|
|
342
|
+
if (nx >= pixelCols || ny >= drawableHeight) continue;
|
|
343
|
+
let nv = hash(nx, ny, phase);
|
|
344
|
+
const nEdgeBias =
|
|
345
|
+
0.12 * ((nx === 0 || nx === pixelCols - 1 ? 1 : 0) + (ny === 0 || ny === drawableHeight - 1 ? 1 : 0));
|
|
346
|
+
nv -= nEdgeBias;
|
|
347
|
+
if (nv < v) {
|
|
348
|
+
winner = false;
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (!winner) continue;
|
|
354
|
+
|
|
355
|
+
// lifecycle (offset = floor(hash(col,row,999) × lifetime))
|
|
356
|
+
const offset = Math.floor(hash(col, row, 999) * lifetime);
|
|
357
|
+
const age = (step + offset) % lifetime;
|
|
358
|
+
if (age > 3) continue;
|
|
359
|
+
|
|
360
|
+
// shimmer (one-frame 8-direction jitter, once, clamped)
|
|
361
|
+
let r = row;
|
|
362
|
+
let c = col;
|
|
363
|
+
if (hash(col, row, 777) < 0.18 && age === 1) {
|
|
364
|
+
const dirs = [
|
|
365
|
+
[-1, -1],
|
|
366
|
+
[-1, 0],
|
|
367
|
+
[-1, 1],
|
|
368
|
+
[0, -1],
|
|
369
|
+
[0, 1],
|
|
370
|
+
[1, -1],
|
|
371
|
+
[1, 0],
|
|
372
|
+
[1, 1],
|
|
373
|
+
] as const;
|
|
374
|
+
const d = dirs[Math.floor(hash(col, row, 555) * dirs.length)]!;
|
|
375
|
+
r = clamp(r + d[0], 0, drawableHeight - 1);
|
|
376
|
+
c = clamp(c + d[1], 0, pixelCols - 1);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
cells[Math.floor(c / 2)]! |= DOT_BITS[r]![c % 2]!;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return cells;
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** The normalizeVariant fallback: a registered variant name maps to itself, everything else (an unknown or unregistered style string) falls back to `typing`. */
|
|
389
|
+
export function normalizeSpinnerStyle(s: string): SpinnerStyle {
|
|
390
|
+
return SPIN_VARIANTS[s as SpinnerStyle] ? (s as SpinnerStyle) : "typing";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** The density tier of an 8-bit dot mask by popcount (the width-1 EAW path for non-typing styles): 0 → space, 1–2 → ░, 3–4 → ▒, 5–6 → ▓, 7–8 → █. */
|
|
394
|
+
export function shadeForMask(mask: number): string {
|
|
395
|
+
let dots = 0;
|
|
396
|
+
for (let b = 0; b < 8; b++) dots += (mask >> b) & 1;
|
|
397
|
+
if (dots === 0) return " ";
|
|
398
|
+
if (dots <= 2) return "░";
|
|
399
|
+
if (dots <= 4) return "▒";
|
|
400
|
+
if (dots <= 6) return "▓";
|
|
401
|
+
return "█";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The border-row spin spinner: a style-configured 4-cell window (the gallery-derived styles in `SPIN_VARIANTS` — batch 1: `typing` — the 8 chart-order stages in 2-step line pairs, then holds, clears, repeats) that fills frame by frame per the style's `compute`; repainted exactly once on the busy→idle transition. Owns the busy/idle tick machine and the precomputed frame table; the editor keeps the timer (style's native tempo × speed mult, 32 ms floor), the guards, and the border-row assembly. Grabbiness is chosen via the same probe as today: `visibleWidth("⣿") === 1` → braille path, otherwise the EAW path — for the typed style that is the existing stage-index shade lookup (byte-identical to the old frames), for other (port) styles the density tier via `shadeForMask`. */
|
|
405
|
+
export class BorderTypeSpinner {
|
|
406
|
+
/** The 4-cell window width inside the border row — both stage sets are 1 wide per stage char. */
|
|
407
|
+
static readonly CELLS = 4;
|
|
408
|
+
|
|
409
|
+
/** Precomputed frame table (computed once in the ctor): covers the `steps − hold` frames `frame()` can address (typing: 32) — the hold tail clamps to the last (fully grown) frame. Deterministic per instance. */
|
|
410
|
+
private readonly frames: string[];
|
|
411
|
+
/** The resolved style config (`SPIN_VARIANTS[normalizeSpinnerStyle(style)]` — an unknown name normalizes to typing). */
|
|
412
|
+
private readonly cfg: SpinStyleConfig;
|
|
413
|
+
private step = 0;
|
|
414
|
+
private wasBusy = false;
|
|
415
|
+
|
|
416
|
+
constructor(
|
|
417
|
+
private readonly isIdle: () => boolean,
|
|
418
|
+
/** The style (config-side typed `SpinnerStyle`, raw setting strings tolerated) — normalized: an unknown / unregistered name renders as typing. */
|
|
419
|
+
style: SpinnerStyle | string = "typing",
|
|
420
|
+
/** The stage-set grabbiness probe — same as today: `visibleWidth("⣿") === 1` → braille chars, otherwise the EAW width-1 path. */
|
|
421
|
+
probe: (s: string) => number = visibleWidth,
|
|
422
|
+
) {
|
|
423
|
+
const resolved = normalizeSpinnerStyle(style);
|
|
424
|
+
this.cfg = SPIN_VARIANTS[resolved]!;
|
|
425
|
+
const braille = probe("⣿") === 1;
|
|
426
|
+
const frames: string[] = [];
|
|
427
|
+
for (
|
|
428
|
+
let i = 0;
|
|
429
|
+
i < this.cfg.steps - this.cfg.hold;
|
|
430
|
+
i++
|
|
431
|
+
) {
|
|
432
|
+
frames.push(
|
|
433
|
+
this.cfg
|
|
434
|
+
.compute(i)
|
|
435
|
+
.map((m) => {
|
|
436
|
+
if (m === 0) return " ";
|
|
437
|
+
if (braille) return String.fromCharCode(0x2800 + m);
|
|
438
|
+
// EAW path: the typed style keeps the existing stage-index shade lookup (byte-identical to the old stage set sequence); other (port) styles take the density tier by their port-masks' popcount.
|
|
439
|
+
return resolved === "typing"
|
|
440
|
+
? STAGE_SHADE[STAGE_MASKS.indexOf(m)]!
|
|
441
|
+
: shadeForMask(m);
|
|
442
|
+
})
|
|
443
|
+
.join(""),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
this.frames = frames;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Advances the busy/idle machine; returns true when the caller should repaint (busy tick, or the busy→idle reset — idle→idle is a full no-op). Resets to the empty clear frame on the busy→idle transition; the step wraps at the style's cycle (typing: 38). */
|
|
450
|
+
tick(): boolean {
|
|
451
|
+
const wasBusy = this.wasBusy;
|
|
452
|
+
const busy = !this.isIdle();
|
|
453
|
+
if (busy) this.step = (this.step + 1) % this.cfg.steps;
|
|
454
|
+
else if (wasBusy) this.step = 0;
|
|
455
|
+
this.wasBusy = busy;
|
|
456
|
+
return busy || wasBusy;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The 4-cell window for the current step: the blank clear beat applies only to beat styles (`hold > 0` — typing: step 0 is 4 spaces, hold tail clamps); `hold = 0` port styles run the full source loop over the precomputed table (step 0 → frames[0] — the real first frame; the last step → frames[steps − 1]). */
|
|
460
|
+
frame(): string {
|
|
461
|
+
const idx =
|
|
462
|
+
this.cfg.hold > 0
|
|
463
|
+
? Math.min(this.step, this.cfg.steps - this.cfg.hold) - 1
|
|
464
|
+
: this.step;
|
|
465
|
+
if (idx < 0) return " ".repeat(BorderTypeSpinner.CELLS);
|
|
466
|
+
return this.frames[idx]!;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Kept for the EAW path's byte-identical typing lookup — the character sets derived from `STAGE_MASKS` (documented for anyone re-porting the stages). */
|
|
471
|
+
export { STAGE_BRAILLE, STAGE_SHADE };
|