@cube-drone/marquee-html-renderer 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/render.js +87 -9
- package/package.json +5 -5
- package/src/render.ts +93 -8
package/dist/render.js
CHANGED
|
@@ -158,7 +158,7 @@ export function usedFontTokens(html) {
|
|
|
158
158
|
}
|
|
159
159
|
/** One rung of the font-element seven-step dial: presentational floor
|
|
160
160
|
* (works with no stylesheet, under any CSP), stylesheet class as ceiling.
|
|
161
|
-
* The named rungs -
|
|
161
|
+
* The named rungs - teeny, tiny, huge, enormous - are unnecessary
|
|
162
162
|
* given the dial, and yet. */
|
|
163
163
|
function sizeRung(value, inner) {
|
|
164
164
|
return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
|
|
@@ -315,6 +315,14 @@ function directive(name, attrs, nodes, ctx) {
|
|
|
315
315
|
}
|
|
316
316
|
case "table":
|
|
317
317
|
return renderTable(attrs, nodes, ctx);
|
|
318
|
+
case "center":
|
|
319
|
+
case "right":
|
|
320
|
+
case "left":
|
|
321
|
+
// The <center> tag, back from the dead in directive clothing - plus
|
|
322
|
+
// its siblings: right for symmetry, left as the un-aligner (useful
|
|
323
|
+
// only inside the other two, which is exactly when you want it).
|
|
324
|
+
// Physical directions, deliberately: predictable beats logical.
|
|
325
|
+
return `<div class="mq-${name}">${inner}</div>`;
|
|
318
326
|
}
|
|
319
327
|
// Unknown vocabulary: a container renders its children with an affordance
|
|
320
328
|
// that something wrapped them; a leaf renders the inert placeholder.
|
|
@@ -404,7 +412,7 @@ function span(name, attrs, nodes, ctx) {
|
|
|
404
412
|
}
|
|
405
413
|
return inner; // not on the list: the words survive in their own clothes
|
|
406
414
|
}
|
|
407
|
-
case "
|
|
415
|
+
case "teeny":
|
|
408
416
|
return sizeRung("1", inner);
|
|
409
417
|
case "tiny":
|
|
410
418
|
return sizeRung("2", inner);
|
|
@@ -419,7 +427,12 @@ function span(name, attrs, nodes, ctx) {
|
|
|
419
427
|
}
|
|
420
428
|
return inner; // invalid value: the effect degrades, the words survive
|
|
421
429
|
}
|
|
422
|
-
case "sidenote":
|
|
430
|
+
case "sidenote":
|
|
431
|
+
case "aside":
|
|
432
|
+
case "footnote": {
|
|
433
|
+
// Permanent synonyms (SPEC.md): nobody keeps these three words
|
|
434
|
+
// distinct in their head, and an aside that silently isn't an aside
|
|
435
|
+
// is worse than two extra names - the list-marker rule, one layer up.
|
|
423
436
|
// A numbered mark in the flow; the note itself flushes just below the
|
|
424
437
|
// triggering paragraph (see flushNotes). Numbering runs sequentially
|
|
425
438
|
// through the whole document.
|
|
@@ -438,18 +451,41 @@ function span(name, attrs, nodes, ctx) {
|
|
|
438
451
|
const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
|
|
439
452
|
? ` style="--mq-rate:${attrs["rate"]}"`
|
|
440
453
|
: "";
|
|
454
|
+
if (attrs["by"] === "letter" || attrs["by"] === "word") {
|
|
455
|
+
// Split blink: ramp is theater-marquee chase lights, scatter is
|
|
456
|
+
// twinkle. The rate var rides the container; units inherit it.
|
|
457
|
+
return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
|
|
458
|
+
}
|
|
441
459
|
return `<span class="mq-blink"${rate}>${inner}</span>`;
|
|
442
460
|
}
|
|
443
461
|
case "rainbow":
|
|
444
462
|
case "bounce":
|
|
445
463
|
case "jitter":
|
|
446
464
|
case "wave":
|
|
465
|
+
case "rubber":
|
|
447
466
|
if (attrs["by"] === "letter" || attrs["by"] === "word") {
|
|
448
467
|
return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
|
|
449
468
|
}
|
|
450
469
|
return `<span class="mq-${name}">${inner}</span>`;
|
|
451
|
-
case "typewriter":
|
|
452
|
-
|
|
470
|
+
case "typewriter": {
|
|
471
|
+
// Inherently per-unit: the reveal IS a by=letter effect (by=word for
|
|
472
|
+
// word-at-a-time). speed= is units per second; the container carries
|
|
473
|
+
// the per-unit delay step, each unit its ordinal in --mq-o.
|
|
474
|
+
const by = attrs["by"] === "word" ? "word" : "letter";
|
|
475
|
+
const step = revealStep(attrs["speed"], 14);
|
|
476
|
+
return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
|
|
477
|
+
}
|
|
478
|
+
case "fadein": {
|
|
479
|
+
// The ghostly reveal. Bare [fadein] fades the whole run in once;
|
|
480
|
+
// by=letter / by=word drift units in on staggered starts (same
|
|
481
|
+
// one-shot family as typewriter: sequential ordinals, high cap);
|
|
482
|
+
// phase=scatter is apparition weather.
|
|
483
|
+
if (attrs["by"] === "letter" || attrs["by"] === "word") {
|
|
484
|
+
const step = revealStep(attrs["speed"], 16);
|
|
485
|
+
return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
|
|
486
|
+
}
|
|
487
|
+
return `<span class="mq-fadein">${inner}</span>`;
|
|
488
|
+
}
|
|
453
489
|
}
|
|
454
490
|
return inner; // unknown span: pure shrug, children as plain content
|
|
455
491
|
}
|
|
@@ -461,9 +497,13 @@ const SEGMENTERS = {
|
|
|
461
497
|
letter: new Intl.Segmenter("en", { granularity: "grapheme" }),
|
|
462
498
|
word: new Intl.Segmenter("en", { granularity: "word" }),
|
|
463
499
|
};
|
|
464
|
-
/** DOM-weight discipline: past this many units, the run animates whole.
|
|
500
|
+
/** DOM-weight discipline: past this many units, the run animates whole.
|
|
501
|
+
* Loopers pay a live animation per element forever, so they cap low;
|
|
502
|
+
* typewriter's units are 1ms one-shots (finished animations cost nothing)
|
|
503
|
+
* and its natural material is long text, so it caps high. */
|
|
465
504
|
const MAX_SPLIT_UNITS = 400;
|
|
466
|
-
|
|
505
|
+
const MAX_REVEAL_UNITS = 2000;
|
|
506
|
+
function bySegments(effect, by, phaseAttr, nodes, ctx, containerStyle = "") {
|
|
467
507
|
// Each effect has a natural phase order (jitter scatters, the rest sweep);
|
|
468
508
|
// the knob overrides it either way. Invalid values degrade to the default.
|
|
469
509
|
const phase = phaseAttr === "scatter" || phaseAttr === "ramp"
|
|
@@ -473,10 +513,11 @@ function bySegments(effect, by, phaseAttr, nodes, ctx) {
|
|
|
473
513
|
: "ramp";
|
|
474
514
|
const state = { effect, by, phase, i: 0, total: 0 };
|
|
475
515
|
state.total = countUnits(nodes, by);
|
|
476
|
-
|
|
516
|
+
const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
|
|
517
|
+
if (state.total === 0 || state.total > cap) {
|
|
477
518
|
return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
|
|
478
519
|
}
|
|
479
|
-
return `<span class="mq-${effect} mq-split">${splitRender(nodes, ctx, state)}</span>`;
|
|
520
|
+
return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
|
|
480
521
|
}
|
|
481
522
|
/** A segment gets wrapped if it's animatable: for words, word-like segments
|
|
482
523
|
* (spaces and bare punctuation ride along); for letters, anything that
|
|
@@ -488,6 +529,19 @@ function isUnit(seg, by) {
|
|
|
488
529
|
* twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
|
|
489
530
|
* integer hash - randomness-shaped, never random. */
|
|
490
531
|
function unitOffset(state) {
|
|
532
|
+
// Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
|
|
533
|
+
// ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
|
|
534
|
+
// looping effects replay. phase=scatter reveals in a
|
|
535
|
+
// scrambled-but-deterministic order: a stride at the run's golden-ratio
|
|
536
|
+
// point, nudged coprime with the total, walks a well-spread permutation
|
|
537
|
+
// at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
|
|
538
|
+
// -1, so forty-unit runs typed in backwards.)
|
|
539
|
+
if (isReveal(state.effect)) {
|
|
540
|
+
const o = state.phase === "scatter"
|
|
541
|
+
? (state.i * scatterStride(state.total)) % state.total
|
|
542
|
+
: state.i;
|
|
543
|
+
return String(o);
|
|
544
|
+
}
|
|
491
545
|
let o;
|
|
492
546
|
if (state.phase === "scatter") {
|
|
493
547
|
o = ((state.i * 7919) % 101) / 101;
|
|
@@ -510,6 +564,30 @@ function unitOffset(state) {
|
|
|
510
564
|
}
|
|
511
565
|
return String(Math.round(o * 1000) / 1000);
|
|
512
566
|
}
|
|
567
|
+
/** speed= (units per second, a COUNT) into a per-unit delay step in
|
|
568
|
+
* seconds; invalid or absent falls to the effect's default rate. */
|
|
569
|
+
function revealStep(speedAttr, dflt) {
|
|
570
|
+
const speed = speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
|
|
571
|
+
? Number(speedAttr)
|
|
572
|
+
: dflt;
|
|
573
|
+
return Math.round(1000 / speed) / 1000;
|
|
574
|
+
}
|
|
575
|
+
/** The one-shot reveals: sequential ordinals, the high unit cap. */
|
|
576
|
+
function isReveal(effect) {
|
|
577
|
+
return effect === "typewriter" || effect === "fadein";
|
|
578
|
+
}
|
|
579
|
+
function gcd(a, b) {
|
|
580
|
+
return b === 0 ? a : gcd(b, a % b);
|
|
581
|
+
}
|
|
582
|
+
/** The smallest stride >= ~61.8% of total that's coprime with it (falls
|
|
583
|
+
* back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
|
|
584
|
+
function scatterStride(total) {
|
|
585
|
+
let stride = Math.max(1, Math.round(total * 0.618));
|
|
586
|
+
while (stride < total && gcd(stride, total) !== 1) {
|
|
587
|
+
stride += 1;
|
|
588
|
+
}
|
|
589
|
+
return gcd(stride, total) === 1 ? stride : 1;
|
|
590
|
+
}
|
|
513
591
|
function countUnits(nodes, by) {
|
|
514
592
|
let n = 0;
|
|
515
593
|
for (const node of nodes) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cube-drone/marquee-html-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Reference static HTML renderer for the Marquee markup language",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"prepack": "npm run build"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@cube-drone/marquee-parser": "^0.
|
|
38
|
+
"@cube-drone/marquee-parser": "^0.3.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@cube-drone/marquee-css": "^0.
|
|
42
|
-
"@cube-drone/marquee-fonts": "^0.
|
|
43
|
-
"@cube-drone/marquee-turbolink": "^0.
|
|
41
|
+
"@cube-drone/marquee-css": "^0.3.0",
|
|
42
|
+
"@cube-drone/marquee-fonts": "^0.3.0",
|
|
43
|
+
"@cube-drone/marquee-turbolink": "^0.3.0",
|
|
44
44
|
"@types/node": "^24.0.0",
|
|
45
45
|
"typescript": "^5.9.0"
|
|
46
46
|
}
|
package/src/render.ts
CHANGED
|
@@ -180,7 +180,7 @@ export function usedFontTokens(html: string): string[] {
|
|
|
180
180
|
|
|
181
181
|
/** One rung of the font-element seven-step dial: presentational floor
|
|
182
182
|
* (works with no stylesheet, under any CSP), stylesheet class as ceiling.
|
|
183
|
-
* The named rungs -
|
|
183
|
+
* The named rungs - teeny, tiny, huge, enormous - are unnecessary
|
|
184
184
|
* given the dial, and yet. */
|
|
185
185
|
function sizeRung(value: string, inner: string): string {
|
|
186
186
|
return `<font class="mq-size-${value}" size="${value}">${inner}</font>`;
|
|
@@ -349,6 +349,14 @@ function directive(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string
|
|
|
349
349
|
}
|
|
350
350
|
case "table":
|
|
351
351
|
return renderTable(attrs, nodes, ctx);
|
|
352
|
+
case "center":
|
|
353
|
+
case "right":
|
|
354
|
+
case "left":
|
|
355
|
+
// The <center> tag, back from the dead in directive clothing - plus
|
|
356
|
+
// its siblings: right for symmetry, left as the un-aligner (useful
|
|
357
|
+
// only inside the other two, which is exactly when you want it).
|
|
358
|
+
// Physical directions, deliberately: predictable beats logical.
|
|
359
|
+
return `<div class="mq-${name}">${inner}</div>`;
|
|
352
360
|
}
|
|
353
361
|
// Unknown vocabulary: a container renders its children with an affordance
|
|
354
362
|
// that something wrapped them; a leaf renders the inert placeholder.
|
|
@@ -440,7 +448,7 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
|
440
448
|
}
|
|
441
449
|
return inner; // not on the list: the words survive in their own clothes
|
|
442
450
|
}
|
|
443
|
-
case "
|
|
451
|
+
case "teeny":
|
|
444
452
|
return sizeRung("1", inner);
|
|
445
453
|
case "tiny":
|
|
446
454
|
return sizeRung("2", inner);
|
|
@@ -455,7 +463,12 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
|
455
463
|
}
|
|
456
464
|
return inner; // invalid value: the effect degrades, the words survive
|
|
457
465
|
}
|
|
458
|
-
case "sidenote":
|
|
466
|
+
case "sidenote":
|
|
467
|
+
case "aside":
|
|
468
|
+
case "footnote": {
|
|
469
|
+
// Permanent synonyms (SPEC.md): nobody keeps these three words
|
|
470
|
+
// distinct in their head, and an aside that silently isn't an aside
|
|
471
|
+
// is worse than two extra names - the list-marker rule, one layer up.
|
|
459
472
|
// A numbered mark in the flow; the note itself flushes just below the
|
|
460
473
|
// triggering paragraph (see flushNotes). Numbering runs sequentially
|
|
461
474
|
// through the whole document.
|
|
@@ -474,18 +487,41 @@ function span(name: string, attrs: Attrs, nodes: Node[], ctx: Ctx): string {
|
|
|
474
487
|
const rate = attrs["rate"] !== undefined && COUNT.test(attrs["rate"])
|
|
475
488
|
? ` style="--mq-rate:${attrs["rate"]}"`
|
|
476
489
|
: "";
|
|
490
|
+
if (attrs["by"] === "letter" || attrs["by"] === "word") {
|
|
491
|
+
// Split blink: ramp is theater-marquee chase lights, scatter is
|
|
492
|
+
// twinkle. The rate var rides the container; units inherit it.
|
|
493
|
+
return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, rate);
|
|
494
|
+
}
|
|
477
495
|
return `<span class="mq-blink"${rate}>${inner}</span>`;
|
|
478
496
|
}
|
|
479
497
|
case "rainbow":
|
|
480
498
|
case "bounce":
|
|
481
499
|
case "jitter":
|
|
482
500
|
case "wave":
|
|
501
|
+
case "rubber":
|
|
483
502
|
if (attrs["by"] === "letter" || attrs["by"] === "word") {
|
|
484
503
|
return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx);
|
|
485
504
|
}
|
|
486
505
|
return `<span class="mq-${name}">${inner}</span>`;
|
|
487
|
-
case "typewriter":
|
|
488
|
-
|
|
506
|
+
case "typewriter": {
|
|
507
|
+
// Inherently per-unit: the reveal IS a by=letter effect (by=word for
|
|
508
|
+
// word-at-a-time). speed= is units per second; the container carries
|
|
509
|
+
// the per-unit delay step, each unit its ordinal in --mq-o.
|
|
510
|
+
const by = attrs["by"] === "word" ? "word" : "letter";
|
|
511
|
+
const step = revealStep(attrs["speed"], 14);
|
|
512
|
+
return bySegments(name, by, attrs["phase"], nodes, ctx, ` style="--mq-tw-step:${step}s"`);
|
|
513
|
+
}
|
|
514
|
+
case "fadein": {
|
|
515
|
+
// The ghostly reveal. Bare [fadein] fades the whole run in once;
|
|
516
|
+
// by=letter / by=word drift units in on staggered starts (same
|
|
517
|
+
// one-shot family as typewriter: sequential ordinals, high cap);
|
|
518
|
+
// phase=scatter is apparition weather.
|
|
519
|
+
if (attrs["by"] === "letter" || attrs["by"] === "word") {
|
|
520
|
+
const step = revealStep(attrs["speed"], 16);
|
|
521
|
+
return bySegments(name, attrs["by"], attrs["phase"], nodes, ctx, ` style="--mq-fi-step:${step}s"`);
|
|
522
|
+
}
|
|
523
|
+
return `<span class="mq-fadein">${inner}</span>`;
|
|
524
|
+
}
|
|
489
525
|
}
|
|
490
526
|
return inner; // unknown span: pure shrug, children as plain content
|
|
491
527
|
}
|
|
@@ -502,8 +538,12 @@ const SEGMENTERS = {
|
|
|
502
538
|
|
|
503
539
|
type SplitBy = keyof typeof SEGMENTERS;
|
|
504
540
|
|
|
505
|
-
/** DOM-weight discipline: past this many units, the run animates whole.
|
|
541
|
+
/** DOM-weight discipline: past this many units, the run animates whole.
|
|
542
|
+
* Loopers pay a live animation per element forever, so they cap low;
|
|
543
|
+
* typewriter's units are 1ms one-shots (finished animations cost nothing)
|
|
544
|
+
* and its natural material is long text, so it caps high. */
|
|
506
545
|
const MAX_SPLIT_UNITS = 400;
|
|
546
|
+
const MAX_REVEAL_UNITS = 2000;
|
|
507
547
|
|
|
508
548
|
type Phase = "ramp" | "scatter";
|
|
509
549
|
|
|
@@ -521,6 +561,7 @@ function bySegments(
|
|
|
521
561
|
phaseAttr: string | undefined,
|
|
522
562
|
nodes: Node[],
|
|
523
563
|
ctx: Ctx,
|
|
564
|
+
containerStyle = "",
|
|
524
565
|
): string {
|
|
525
566
|
// Each effect has a natural phase order (jitter scatters, the rest sweep);
|
|
526
567
|
// the knob overrides it either way. Invalid values degrade to the default.
|
|
@@ -532,10 +573,11 @@ function bySegments(
|
|
|
532
573
|
: "ramp";
|
|
533
574
|
const state: SplitState = { effect, by, phase, i: 0, total: 0 };
|
|
534
575
|
state.total = countUnits(nodes, by);
|
|
535
|
-
|
|
576
|
+
const cap = isReveal(effect) ? MAX_REVEAL_UNITS : MAX_SPLIT_UNITS;
|
|
577
|
+
if (state.total === 0 || state.total > cap) {
|
|
536
578
|
return `<span class="mq-${effect}">${children(nodes, ctx)}</span>`;
|
|
537
579
|
}
|
|
538
|
-
return `<span class="mq-${effect} mq-split">${splitRender(nodes, ctx, state)}</span>`;
|
|
580
|
+
return `<span class="mq-${effect} mq-split"${containerStyle}>${splitRender(nodes, ctx, state)}</span>`;
|
|
539
581
|
}
|
|
540
582
|
|
|
541
583
|
/** A segment gets wrapped if it's animatable: for words, word-like segments
|
|
@@ -549,6 +591,20 @@ function isUnit(seg: Intl.SegmentData, by: SplitBy): boolean {
|
|
|
549
591
|
* twice) in both phase orders: ramp sweeps, scatter scrambles by a fixed
|
|
550
592
|
* integer hash - randomness-shaped, never random. */
|
|
551
593
|
function unitOffset(state: SplitState): string {
|
|
594
|
+
// Reveal offsets (typewriter, fadein) are sequential INTEGERS (the
|
|
595
|
+
// ordinal; delay = ordinal x step), unlike the cyclic 0..1 fractions the
|
|
596
|
+
// looping effects replay. phase=scatter reveals in a
|
|
597
|
+
// scrambled-but-deterministic order: a stride at the run's golden-ratio
|
|
598
|
+
// point, nudged coprime with the total, walks a well-spread permutation
|
|
599
|
+
// at EVERY length. (A fixed prime stride is a trap: 7919 mod 40 = 39 =
|
|
600
|
+
// -1, so forty-unit runs typed in backwards.)
|
|
601
|
+
if (isReveal(state.effect)) {
|
|
602
|
+
const o =
|
|
603
|
+
state.phase === "scatter"
|
|
604
|
+
? (state.i * scatterStride(state.total)) % state.total
|
|
605
|
+
: state.i;
|
|
606
|
+
return String(o);
|
|
607
|
+
}
|
|
552
608
|
let o: number;
|
|
553
609
|
if (state.phase === "scatter") {
|
|
554
610
|
o = ((state.i * 7919) % 101) / 101;
|
|
@@ -571,6 +627,35 @@ function unitOffset(state: SplitState): string {
|
|
|
571
627
|
return String(Math.round(o * 1000) / 1000);
|
|
572
628
|
}
|
|
573
629
|
|
|
630
|
+
/** speed= (units per second, a COUNT) into a per-unit delay step in
|
|
631
|
+
* seconds; invalid or absent falls to the effect's default rate. */
|
|
632
|
+
function revealStep(speedAttr: string | undefined, dflt: number): number {
|
|
633
|
+
const speed =
|
|
634
|
+
speedAttr !== undefined && COUNT.test(speedAttr) && Number(speedAttr) > 0
|
|
635
|
+
? Number(speedAttr)
|
|
636
|
+
: dflt;
|
|
637
|
+
return Math.round(1000 / speed) / 1000;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** The one-shot reveals: sequential ordinals, the high unit cap. */
|
|
641
|
+
function isReveal(effect: string): boolean {
|
|
642
|
+
return effect === "typewriter" || effect === "fadein";
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function gcd(a: number, b: number): number {
|
|
646
|
+
return b === 0 ? a : gcd(b, a % b);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** The smallest stride >= ~61.8% of total that's coprime with it (falls
|
|
650
|
+
* back to 1 for degenerate totals - a 1- or 2-unit "scramble" is fate). */
|
|
651
|
+
function scatterStride(total: number): number {
|
|
652
|
+
let stride = Math.max(1, Math.round(total * 0.618));
|
|
653
|
+
while (stride < total && gcd(stride, total) !== 1) {
|
|
654
|
+
stride += 1;
|
|
655
|
+
}
|
|
656
|
+
return gcd(stride, total) === 1 ? stride : 1;
|
|
657
|
+
}
|
|
658
|
+
|
|
574
659
|
function countUnits(nodes: Node[], by: SplitBy): number {
|
|
575
660
|
let n = 0;
|
|
576
661
|
for (const node of nodes) {
|