@idle-screens/schema 3.5.0 → 3.6.1
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/FORMAT.md +249 -17
- package/dist/index.d.ts +318 -3
- package/dist/index.js +1041 -62
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/saver-spec.schema.json +285 -36
package/dist/index.js
CHANGED
|
@@ -55,17 +55,41 @@ var LIMITS = {
|
|
|
55
55
|
minSegmentDuration: 1e3,
|
|
56
56
|
// ms — flash-safety floor: segment cuts are luminance transitions
|
|
57
57
|
maxTransitionDur: 5e3,
|
|
58
|
-
minTransitionDur: 200
|
|
58
|
+
minTransitionDur: 200,
|
|
59
|
+
minEmitEvery: 1e3,
|
|
60
|
+
// ms — at most one event per second per entity (flash safety)
|
|
61
|
+
maxEmitEvery: 6e5,
|
|
62
|
+
// ms — ten minutes; longer silences than that are `life.enter`
|
|
63
|
+
minEmitLife: 500,
|
|
64
|
+
// ms — an event is a smooth envelope, never a cut
|
|
65
|
+
maxEmitGrow: 8,
|
|
66
|
+
// × base size across an event window
|
|
67
|
+
minClockedPeriod: 1e3,
|
|
68
|
+
// ms — a `clock`ed layer breathes in unison, so 1 Hz not 2
|
|
69
|
+
minClockRate: 0.1,
|
|
70
|
+
maxClockRate: 4,
|
|
71
|
+
defaultListGap: 0.06,
|
|
72
|
+
// viewport units between list/table cells (40 px in px specs)
|
|
73
|
+
defaultListGapPx: 40,
|
|
74
|
+
minPolygonSides: 3,
|
|
75
|
+
maxPolygonSides: 12,
|
|
76
|
+
maxShapePoints: 24,
|
|
77
|
+
// polygon.points / stroke.points
|
|
78
|
+
minEaseTau: 100,
|
|
79
|
+
// ms
|
|
80
|
+
maxEaseTau: 12e4
|
|
81
|
+
// ms
|
|
59
82
|
};
|
|
60
83
|
|
|
61
84
|
// src/steer.ts
|
|
62
85
|
var UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
86
|
+
var STEERABLE_ROOT_KEYS = /* @__PURE__ */ new Set(["ghosting", "referenceViewport"]);
|
|
63
87
|
function resolveSpecPath(spec, path) {
|
|
64
88
|
if (!spec || typeof spec !== "object" || !path) return null;
|
|
65
89
|
const parts = path.split(".");
|
|
66
90
|
if (parts.some((p) => UNSAFE_KEYS.has(p))) return null;
|
|
67
91
|
const s = spec;
|
|
68
|
-
if (parts[0] !== "layers" && parts[0] !== "background" && Array.isArray(s.layers)) {
|
|
92
|
+
if (parts[0] !== "layers" && parts[0] !== "background" && !STEERABLE_ROOT_KEYS.has(parts[0]) && Array.isArray(s.layers)) {
|
|
69
93
|
const idx = s.layers.findIndex((l) => l && l.key === parts[0]);
|
|
70
94
|
if (idx === -1) return null;
|
|
71
95
|
parts.splice(0, 1, "layers", String(idx));
|
|
@@ -127,7 +151,7 @@ function lerpSpec(from, to, k) {
|
|
|
127
151
|
}
|
|
128
152
|
function steerablePaths(spec) {
|
|
129
153
|
if (!spec || typeof spec !== "object") return [];
|
|
130
|
-
const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "mode", "curve", "layer"]);
|
|
154
|
+
const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units", "motionIntensity", "mode", "curve", "layer"]);
|
|
131
155
|
const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
|
|
132
156
|
const out = [];
|
|
133
157
|
const walk = (node, prefix, key) => {
|
|
@@ -158,6 +182,7 @@ function easeSmooth(k) {
|
|
|
158
182
|
function structuralSignature(spec) {
|
|
159
183
|
return JSON.stringify([
|
|
160
184
|
spec.units,
|
|
185
|
+
spec.referenceViewport,
|
|
161
186
|
spec.layers.map((l) => {
|
|
162
187
|
const s = l.sprite;
|
|
163
188
|
return [
|
|
@@ -173,12 +198,14 @@ function structuralSignature(spec) {
|
|
|
173
198
|
l.spin,
|
|
174
199
|
l.grow,
|
|
175
200
|
l.layout,
|
|
201
|
+
l.emit,
|
|
202
|
+
l.clock,
|
|
176
203
|
l.sprite.kind,
|
|
177
204
|
// Dimensional draws baked into entities: radius (circle/ring), length
|
|
178
205
|
// (streak), width+aspect (rect), palette pick (all shaped sprites).
|
|
179
206
|
s.radius,
|
|
180
207
|
s.length,
|
|
181
|
-
l.sprite.kind === "rect" ? [s.width, s.aspect] : void 0,
|
|
208
|
+
l.sprite.kind === "rect" ? [s.width, s.aspect] : l.sprite.kind === "bar" ? [s.length, s.thickness] : void 0,
|
|
182
209
|
Array.isArray(s.colors) ? s.colors.length : void 0,
|
|
183
210
|
s.colorWeights,
|
|
184
211
|
l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : l.sprite.kind === "textBlock" ? [l.sprite.fontSize] : void 0
|
|
@@ -193,7 +220,7 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
193
220
|
var isStr = (v) => typeof v === "string";
|
|
194
221
|
var isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
195
222
|
var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
|
|
196
|
-
var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "units", "referenceViewport", "background", "layers", "ghosting"]);
|
|
223
|
+
var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "density", "units", "referenceViewport", "background", "layers", "ghosting"]);
|
|
197
224
|
var KNOWN_LAYER = /* @__PURE__ */ new Set([
|
|
198
225
|
"count",
|
|
199
226
|
"sprite",
|
|
@@ -212,28 +239,33 @@ var KNOWN_LAYER = /* @__PURE__ */ new Set([
|
|
|
212
239
|
"trail",
|
|
213
240
|
"links",
|
|
214
241
|
"layout",
|
|
215
|
-
"life"
|
|
242
|
+
"life",
|
|
243
|
+
"emit",
|
|
244
|
+
"clock"
|
|
216
245
|
]);
|
|
217
246
|
var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors", "colorWeights"]);
|
|
218
247
|
var KNOWN_RING = /* @__PURE__ */ new Set(["kind", "radius", "color", "width", "colors", "colorWeights"]);
|
|
219
248
|
var KNOWN_STREAK = /* @__PURE__ */ new Set(["kind", "length", "color", "width", "colors", "colorWeights"]);
|
|
220
|
-
var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "colors", "colorWeights"]);
|
|
249
|
+
var KNOWN_RECT = /* @__PURE__ */ new Set(["kind", "width", "aspect", "color", "feather", "colors", "colorWeights"]);
|
|
250
|
+
var KNOWN_BAR = /* @__PURE__ */ new Set(["kind", "values", "length", "thickness", "color", "max", "direction", "colors", "colorWeights"]);
|
|
251
|
+
var KNOWN_POLYGON = /* @__PURE__ */ new Set(["kind", "radius", "color", "sides", "points", "soft", "colors", "colorWeights"]);
|
|
252
|
+
var KNOWN_STROKE = /* @__PURE__ */ new Set(["kind", "length", "points", "color", "width", "curve", "taper", "orient", "colors", "colorWeights"]);
|
|
221
253
|
var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
|
|
222
254
|
var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
|
|
223
255
|
var KNOWN_TEXT_BLOCK = /* @__PURE__ */ new Set(["kind", "text", "maxWidth", "fontSize", "lineHeight", "align", "color", "reveal"]);
|
|
224
256
|
var KNOWN_REVEAL = /* @__PURE__ */ new Set(["progress", "mode", "speed", "caret", "fade"]);
|
|
225
257
|
var KNOWN_REVEAL_CARET = /* @__PURE__ */ new Set(["blink", "color"]);
|
|
226
|
-
var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
|
|
227
|
-
var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
|
|
258
|
+
var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob", "ease"]);
|
|
259
|
+
var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway", "ease"]);
|
|
228
260
|
var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
|
|
229
261
|
var KNOWN_STATIC = /* @__PURE__ */ new Set(["type"]);
|
|
230
262
|
var KNOWN_ORBIT = /* @__PURE__ */ new Set(["type", "speed", "radius", "center"]);
|
|
231
|
-
var KNOWN_WANDER = /* @__PURE__ */ new Set(["type", "speed", "angle", "meander", "coherence"]);
|
|
263
|
+
var KNOWN_WANDER = /* @__PURE__ */ new Set(["type", "speed", "angle", "meander", "coherence", "ease"]);
|
|
232
264
|
var KNOWN_WARP = /* @__PURE__ */ new Set(["type", "speed", "center"]);
|
|
233
265
|
var KNOWN_PATH = /* @__PURE__ */ new Set(["type", "points", "duration", "curve", "closed", "scatter"]);
|
|
234
266
|
var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
|
|
235
267
|
var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
|
|
236
|
-
var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key"]);
|
|
268
|
+
var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key", "emit", "clock", "life", "layout"]);
|
|
237
269
|
function unknownKeys(obj, known) {
|
|
238
270
|
return Object.keys(obj).filter((k) => !known.has(k));
|
|
239
271
|
}
|
|
@@ -263,6 +295,9 @@ function validateSpec(spec) {
|
|
|
263
295
|
if (spec.motionIntensity !== void 0 && !["calm", "moderate", "energetic"].includes(spec.motionIntensity)) {
|
|
264
296
|
err("motionIntensity", "must be calm | moderate | energetic");
|
|
265
297
|
}
|
|
298
|
+
if (spec.density !== void 0 && !["sparse", "normal", "dense"].includes(spec.density)) {
|
|
299
|
+
err("density", "must be sparse | normal | dense");
|
|
300
|
+
}
|
|
266
301
|
if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
|
|
267
302
|
err("units", "must be 'px' | 'viewport'");
|
|
268
303
|
}
|
|
@@ -379,7 +414,10 @@ function validateLayer(layer, path, err, warn, spec) {
|
|
|
379
414
|
} else {
|
|
380
415
|
if (layer.position.x < 0 || layer.position.x > 1) err(`${path}.position.x`, "must be 0..1");
|
|
381
416
|
if (layer.position.y < 0 || layer.position.y > 1) err(`${path}.position.y`, "must be 0..1");
|
|
382
|
-
|
|
417
|
+
const dataLayout = isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table");
|
|
418
|
+
if (isNum(layer.count) && layer.count !== 1 && !dataLayout) {
|
|
419
|
+
err(`${path}.position`, "position requires count: 1 \u2014 or a layout of type 'list' / 'table', where it anchors the whole block");
|
|
420
|
+
}
|
|
383
421
|
}
|
|
384
422
|
}
|
|
385
423
|
if (layer.region !== void 0) {
|
|
@@ -468,8 +506,37 @@ function validateLayer(layer, path, err, warn, spec) {
|
|
|
468
506
|
}
|
|
469
507
|
}
|
|
470
508
|
if (layer.layout !== void 0) {
|
|
471
|
-
if (
|
|
472
|
-
|
|
509
|
+
if (isObj(layer.layout) && (layer.layout.type === "list" || layer.layout.type === "table")) {
|
|
510
|
+
const lay = layer.layout;
|
|
511
|
+
if (lay.type === "table" && (!isNum(lay.columns) || !Number.isInteger(lay.columns) || lay.columns < 1 || lay.columns > LIMITS.maxGridColumns)) {
|
|
512
|
+
err(`${path}.layout.columns`, `must be an integer 1..${LIMITS.maxGridColumns}`);
|
|
513
|
+
}
|
|
514
|
+
const gapOk = (v) => isNum(v) && v > 0;
|
|
515
|
+
if (lay.gap !== void 0) {
|
|
516
|
+
if (isNum(lay.gap)) {
|
|
517
|
+
if (!gapOk(lay.gap)) err(`${path}.layout.gap`, "must be > 0 (viewport units of min(w,h), or px)");
|
|
518
|
+
} else if (lay.type === "table" && isObj(lay.gap)) {
|
|
519
|
+
if (lay.gap.x !== void 0 && !gapOk(lay.gap.x)) err(`${path}.layout.gap.x`, "must be > 0");
|
|
520
|
+
if (lay.gap.y !== void 0 && !gapOk(lay.gap.y)) err(`${path}.layout.gap.y`, "must be > 0");
|
|
521
|
+
for (const k of unknownKeys(lay.gap, /* @__PURE__ */ new Set(["x", "y"]))) {
|
|
522
|
+
warn(`${path}.layout.gap.${k}`, "unknown-property", `unknown gap property '${k}' \u2014 will be ignored`);
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
err(`${path}.layout.gap`, lay.type === "table" ? "must be a number > 0 or { x?, y? }" : "must be a number > 0");
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
for (const k of unknownKeys(lay, new Set(lay.type === "list" ? ["type", "gap"] : ["type", "columns", "gap"]))) {
|
|
529
|
+
warn(`${path}.layout.${k}`, "unknown-property", `unknown layout property '${k}' \u2014 will be ignored`);
|
|
530
|
+
}
|
|
531
|
+
const sp = layer.sprite;
|
|
532
|
+
if (isObj(sp) && isNum(layer.count)) {
|
|
533
|
+
const variants = sp.kind === "text" && Array.isArray(sp.strings) ? sp.strings.length : sp.kind === "emoji" && Array.isArray(sp.glyphs) ? sp.glyphs.length : sp.kind === "bar" && Array.isArray(sp.values) ? sp.values.length : null;
|
|
534
|
+
if (variants !== null && variants !== layer.count) {
|
|
535
|
+
warn(`${path}.count`, "list-length-mismatch", `count is ${layer.count} but the sprite carries ${variants} ${sp.kind === "bar" ? "values" : "variants"} \u2014 a ${lay.type} layout reads them in order, so they cycle or go unused`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
} else if (!isObj(layer.layout) || layer.layout.type !== "grid") {
|
|
539
|
+
err(`${path}.layout`, "must be an object with type: 'grid' | 'list' | 'table'");
|
|
473
540
|
} else {
|
|
474
541
|
if (layer.layout.columns !== void 0 && (!isNum(layer.layout.columns) || !Number.isInteger(layer.layout.columns) || layer.layout.columns < 1 || layer.layout.columns > LIMITS.maxGridColumns)) {
|
|
475
542
|
err(`${path}.layout.columns`, `must be an integer 1..${LIMITS.maxGridColumns}`);
|
|
@@ -519,6 +586,64 @@ function validateLayer(layer, path, err, warn, spec) {
|
|
|
519
586
|
}
|
|
520
587
|
}
|
|
521
588
|
}
|
|
589
|
+
if (layer.emit !== void 0) {
|
|
590
|
+
if (!isObj(layer.emit)) err(`${path}.emit`, "must be an object { every, life, jitter?, grow? }");
|
|
591
|
+
else {
|
|
592
|
+
const em = layer.emit;
|
|
593
|
+
if (!isNum(em.every) || em.every < LIMITS.minEmitEvery || em.every > LIMITS.maxEmitEvery) {
|
|
594
|
+
err(`${path}.emit.every`, `must be ${LIMITS.minEmitEvery}..${LIMITS.maxEmitEvery} ms (at most one event per second per entity)`);
|
|
595
|
+
}
|
|
596
|
+
if (!isNum(em.life) || em.life < LIMITS.minEmitLife) {
|
|
597
|
+
err(`${path}.emit.life`, `must be >= ${LIMITS.minEmitLife} ms (an event is a smooth envelope, never a cut)`);
|
|
598
|
+
} else if (isNum(em.every) && em.life > em.every) {
|
|
599
|
+
err(`${path}.emit.life`, "must be <= emit.every (the visible window cannot outlast its period)");
|
|
600
|
+
}
|
|
601
|
+
if (em.jitter !== void 0 && (!isNum(em.jitter) || em.jitter < 0 || em.jitter > 1)) {
|
|
602
|
+
err(`${path}.emit.jitter`, "must be 0..1 (0 = evenly staggered, 1 = scattered by a fixed sequence)");
|
|
603
|
+
}
|
|
604
|
+
if (em.grow !== void 0) {
|
|
605
|
+
if (!Array.isArray(em.grow) || em.grow.length !== 2 || !isNum(em.grow[0]) || !isNum(em.grow[1])) {
|
|
606
|
+
err(`${path}.emit.grow`, "must be [from, to] size multipliers");
|
|
607
|
+
} else if (em.grow[0] < 0 || em.grow[1] < 0 || em.grow[0] > LIMITS.maxEmitGrow || em.grow[1] > LIMITS.maxEmitGrow) {
|
|
608
|
+
err(`${path}.emit.grow`, `each multiplier must be 0..${LIMITS.maxEmitGrow}`);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (em.jitter === 0 && isNum(em.every) && isNum(em.life) && isNum(layer.count) && layer.count > 1 && em.life > em.every / layer.count) {
|
|
612
|
+
warn(`${path}.emit.life`, "emit-overlap", `jitter 0 staggers ${layer.count} entities ${(em.every / layer.count).toFixed(0)} ms apart but each stays lit ${em.life} ms \u2014 windows overlap, so more than one event is visible at a time. Shorten life or lengthen every`);
|
|
613
|
+
}
|
|
614
|
+
for (const k of unknownKeys(em, /* @__PURE__ */ new Set(["every", "life", "jitter", "grow"]))) {
|
|
615
|
+
warn(`${path}.emit.${k}`, "unknown-property", `unknown emit property '${k}' \u2014 will be ignored`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (layer.clock !== void 0) {
|
|
620
|
+
if (!isObj(layer.clock)) err(`${path}.clock`, "must be an object { phase?, rate? }");
|
|
621
|
+
else {
|
|
622
|
+
const ck = layer.clock;
|
|
623
|
+
if (ck.phase !== void 0 && (!isNum(ck.phase) || ck.phase < 0 || ck.phase > 1)) {
|
|
624
|
+
err(`${path}.clock.phase`, "must be 0..1 (turns)");
|
|
625
|
+
}
|
|
626
|
+
const rate = ck.rate === void 0 ? 1 : ck.rate;
|
|
627
|
+
if (!isNum(rate) || rate < LIMITS.minClockRate || rate > LIMITS.maxClockRate) {
|
|
628
|
+
err(`${path}.clock.rate`, `must be ${LIMITS.minClockRate}..${LIMITS.maxClockRate}`);
|
|
629
|
+
} else {
|
|
630
|
+
const floor = LIMITS.minClockedPeriod * rate;
|
|
631
|
+
if (isObj(layer.pulse) && isNum(layer.pulse.period) && layer.pulse.period < floor) {
|
|
632
|
+
err(`${path}.pulse.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod} \u2014 the whole layer breathes in unison)`);
|
|
633
|
+
}
|
|
634
|
+
if (isObj(layer.grow) && isNum(layer.grow.period) && layer.grow.period < floor) {
|
|
635
|
+
err(`${path}.grow.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod})`);
|
|
636
|
+
}
|
|
637
|
+
const sp = layer.sprite;
|
|
638
|
+
if (isObj(sp) && isObj(sp.cycle) && isNum(sp.cycle.period) && sp.cycle.period < floor) {
|
|
639
|
+
err(`${path}.sprite.cycle.period`, `must be >= ${floor} ms when the layer has a clock (period / rate >= ${LIMITS.minClockedPeriod})`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
for (const k of unknownKeys(ck, /* @__PURE__ */ new Set(["phase", "rate"]))) {
|
|
643
|
+
warn(`${path}.clock.${k}`, "unknown-property", `unknown clock property '${k}' \u2014 will be ignored`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
522
647
|
validateSprite(layer.sprite, `${path}.sprite`, err, warn);
|
|
523
648
|
validateMotion(layer.motion, `${path}.motion`, err, warn, spec);
|
|
524
649
|
}
|
|
@@ -537,6 +662,9 @@ function validateSprite(sprite, path, err, warn) {
|
|
|
537
662
|
err(`${path}.strings`, "must be a non-empty array of strings");
|
|
538
663
|
}
|
|
539
664
|
if (sprite.color !== void 0) color(sprite.color, `${path}.color`, err);
|
|
665
|
+
if (sprite.font !== void 0 && !isStr(sprite.font)) {
|
|
666
|
+
err(`${path}.font`, "must be a string");
|
|
667
|
+
}
|
|
540
668
|
if (sprite.align !== void 0 && !["left", "center", "right"].includes(sprite.align)) {
|
|
541
669
|
err(`${path}.align`, "must be left | center | right");
|
|
542
670
|
}
|
|
@@ -571,6 +699,45 @@ function validateSprite(sprite, path, err, warn) {
|
|
|
571
699
|
if (sprite.aspect !== void 0 && (!isRange(sprite.aspect) || sprite.aspect[0] <= 0)) {
|
|
572
700
|
err(`${path}.aspect`, "must be a [min,max] range of positive height/width ratios");
|
|
573
701
|
}
|
|
702
|
+
if (sprite.feather !== void 0 && (!isNum(sprite.feather) || sprite.feather < 0 || sprite.feather > 1)) {
|
|
703
|
+
err(`${path}.feather`, "must be 0..1 (fraction of the half-size that fades out)");
|
|
704
|
+
}
|
|
705
|
+
color(sprite.color, `${path}.color`, err);
|
|
706
|
+
validatePalette(sprite, path, err);
|
|
707
|
+
} else if (sprite.kind === "bar") {
|
|
708
|
+
knownSet = KNOWN_BAR;
|
|
709
|
+
if (!Array.isArray(sprite.values) || sprite.values.length === 0 || sprite.values.length > LIMITS.maxPerLayer || !sprite.values.every((v) => isNum(v) && v >= 0)) {
|
|
710
|
+
err(`${path}.values`, `must be 1..${LIMITS.maxPerLayer} numbers >= 0`);
|
|
711
|
+
}
|
|
712
|
+
if (!isNum(sprite.length) || sprite.length <= 0) err(`${path}.length`, "must be > 0 (full-scale bar length)");
|
|
713
|
+
if (!isNum(sprite.thickness) || sprite.thickness <= 0) err(`${path}.thickness`, "must be > 0");
|
|
714
|
+
if (sprite.max !== void 0 && (!isNum(sprite.max) || sprite.max <= 0)) err(`${path}.max`, "must be > 0");
|
|
715
|
+
if (sprite.direction !== void 0 && !["right", "left", "up", "down"].includes(sprite.direction)) {
|
|
716
|
+
err(`${path}.direction`, "must be 'right' | 'left' | 'up' | 'down'");
|
|
717
|
+
}
|
|
718
|
+
color(sprite.color, `${path}.color`, err);
|
|
719
|
+
validatePalette(sprite, path, err);
|
|
720
|
+
} else if (sprite.kind === "polygon") {
|
|
721
|
+
knownSet = KNOWN_POLYGON;
|
|
722
|
+
if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px (circumradius)");
|
|
723
|
+
if (sprite.sides !== void 0 && sprite.points !== void 0) {
|
|
724
|
+
err(`${path}.sides`, "use sides (a regular polygon) or points (a custom one), not both");
|
|
725
|
+
}
|
|
726
|
+
if (sprite.sides !== void 0 && (!isNum(sprite.sides) || !Number.isInteger(sprite.sides) || sprite.sides < LIMITS.minPolygonSides || sprite.sides > LIMITS.maxPolygonSides)) {
|
|
727
|
+
err(`${path}.sides`, `must be an integer ${LIMITS.minPolygonSides}..${LIMITS.maxPolygonSides}`);
|
|
728
|
+
}
|
|
729
|
+
if (sprite.points !== void 0) validateShapePoints(sprite.points, `${path}.points`, 3, err);
|
|
730
|
+
if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
|
|
731
|
+
color(sprite.color, `${path}.color`, err);
|
|
732
|
+
validatePalette(sprite, path, err);
|
|
733
|
+
} else if (sprite.kind === "stroke") {
|
|
734
|
+
knownSet = KNOWN_STROKE;
|
|
735
|
+
if (!isRange(sprite.length) || sprite.length[0] <= 0) err(`${path}.length`, "must be a [min,max] range of positive px (the mark's bounding size)");
|
|
736
|
+
validateShapePoints(sprite.points, `${path}.points`, 2, err);
|
|
737
|
+
if (sprite.width !== void 0 && (!isNum(sprite.width) || sprite.width <= 0)) err(`${path}.width`, "must be > 0");
|
|
738
|
+
if (sprite.curve !== void 0 && sprite.curve !== "smooth" && sprite.curve !== "linear") err(`${path}.curve`, "must be 'smooth' | 'linear'");
|
|
739
|
+
if (sprite.taper !== void 0 && typeof sprite.taper !== "boolean") err(`${path}.taper`, "must be a boolean");
|
|
740
|
+
if (sprite.orient !== void 0 && typeof sprite.orient !== "boolean") err(`${path}.orient`, "must be a boolean");
|
|
574
741
|
color(sprite.color, `${path}.color`, err);
|
|
575
742
|
validatePalette(sprite, path, err);
|
|
576
743
|
} else if (sprite.kind === "textBlock") {
|
|
@@ -630,7 +797,7 @@ function validateSprite(sprite, path, err, warn) {
|
|
|
630
797
|
}
|
|
631
798
|
}
|
|
632
799
|
} else {
|
|
633
|
-
err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | textBlock");
|
|
800
|
+
err(`${path}.kind`, "must be emoji | text | circle | ring | streak | rect | bar | polygon | stroke | textBlock");
|
|
634
801
|
return;
|
|
635
802
|
}
|
|
636
803
|
for (const k of unknownKeys(sprite, knownSet)) {
|
|
@@ -683,6 +850,7 @@ function validateMotion(motion, path, err, warn, spec) {
|
|
|
683
850
|
if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
|
|
684
851
|
if (motion.bidirectional !== void 0 && typeof motion.bidirectional !== "boolean") err(`${path}.bidirectional`, "must be a boolean");
|
|
685
852
|
if (motion.bob !== void 0 && !isNum(motion.bob)) err(`${path}.bob`, "must be a number");
|
|
853
|
+
validateEase(motion.ease, `${path}.ease`, err, warn);
|
|
686
854
|
if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
|
|
687
855
|
warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 10\u2013200 px/sec`);
|
|
688
856
|
}
|
|
@@ -690,6 +858,7 @@ function validateMotion(motion, path, err, warn, spec) {
|
|
|
690
858
|
knownSet = KNOWN_RISE;
|
|
691
859
|
speedOk(motion.speed, `${path}.speed`);
|
|
692
860
|
if (motion.sway !== void 0 && !isNum(motion.sway)) err(`${path}.sway`, "must be a number");
|
|
861
|
+
validateEase(motion.ease, `${path}.ease`, err, warn);
|
|
693
862
|
if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
|
|
694
863
|
warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 5\u201380 px/sec`);
|
|
695
864
|
}
|
|
@@ -739,6 +908,7 @@ function validateMotion(motion, path, err, warn, spec) {
|
|
|
739
908
|
}
|
|
740
909
|
} else if (motion.type === "wander") {
|
|
741
910
|
knownSet = KNOWN_WANDER;
|
|
911
|
+
validateEase(motion.ease, `${path}.ease`, err, warn);
|
|
742
912
|
speedOk(motion.speed, `${path}.speed`);
|
|
743
913
|
if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
|
|
744
914
|
const meanderCap = isViewport ? LIMITS.maxMeander / refVp : LIMITS.maxMeander;
|
|
@@ -790,6 +960,28 @@ function validateMotion(motion, path, err, warn, spec) {
|
|
|
790
960
|
warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
|
|
791
961
|
}
|
|
792
962
|
}
|
|
963
|
+
function validateShapePoints(points, path, min, err) {
|
|
964
|
+
if (!Array.isArray(points) || points.length < min || points.length > LIMITS.maxShapePoints) {
|
|
965
|
+
return err(path, `must be ${min}..${LIMITS.maxShapePoints} [x, y] pairs in unit coordinates (-1..1)`);
|
|
966
|
+
}
|
|
967
|
+
for (let i = 0; i < points.length; i++) {
|
|
968
|
+
const pt = points[i];
|
|
969
|
+
if (!Array.isArray(pt) || pt.length !== 2 || !isNum(pt[0]) || !isNum(pt[1]) || Math.abs(pt[0]) > 1 || Math.abs(pt[1]) > 1) {
|
|
970
|
+
err(`${path}[${i}]`, "must be an [x, y] pair with each coordinate in -1..1");
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
function validateEase(ease, path, err, warn) {
|
|
975
|
+
if (ease === void 0) return;
|
|
976
|
+
if (!isObj(ease)) return err(path, "must be an object { type: 'settle' | 'buoyant', tau }");
|
|
977
|
+
if (ease.type !== "settle" && ease.type !== "buoyant") err(`${path}.type`, "must be 'settle' | 'buoyant'");
|
|
978
|
+
if (!isNum(ease.tau) || ease.tau < LIMITS.minEaseTau || ease.tau > LIMITS.maxEaseTau) {
|
|
979
|
+
err(`${path}.tau`, `must be ${LIMITS.minEaseTau}..${LIMITS.maxEaseTau} ms`);
|
|
980
|
+
}
|
|
981
|
+
for (const k of unknownKeys(ease, /* @__PURE__ */ new Set(["type", "tau"]))) {
|
|
982
|
+
warn(`${path}.${k}`, "unknown-property", `unknown ease property '${k}' \u2014 will be ignored`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
793
985
|
function assertValidSpec(spec) {
|
|
794
986
|
const r = validateSpec(spec);
|
|
795
987
|
if (!r.valid) {
|
|
@@ -907,6 +1099,110 @@ ${r.errors.map((e) => ` ${e.path || "<root>"}: ${e.message}`).join("\n")}`);
|
|
|
907
1099
|
return seq;
|
|
908
1100
|
}
|
|
909
1101
|
|
|
1102
|
+
// src/shapes.ts
|
|
1103
|
+
function isShapedSprite(s) {
|
|
1104
|
+
return s.kind === "circle" || s.kind === "ring" || s.kind === "streak" || s.kind === "rect" || s.kind === "polygon" || s.kind === "stroke" || s.kind === "bar";
|
|
1105
|
+
}
|
|
1106
|
+
function barFraction(s, index) {
|
|
1107
|
+
const n = s.values.length;
|
|
1108
|
+
if (n === 0) return 0;
|
|
1109
|
+
const v = s.values[(index % n + n) % n] ?? 0;
|
|
1110
|
+
const max = s.max ?? s.values.reduce((m, x) => Math.max(m, x), 0);
|
|
1111
|
+
if (!(max > 0)) return 0;
|
|
1112
|
+
return Math.max(0, Math.min(1, v / max));
|
|
1113
|
+
}
|
|
1114
|
+
function barBox(direction, len, thick) {
|
|
1115
|
+
switch (direction) {
|
|
1116
|
+
case "left":
|
|
1117
|
+
return { cx: -len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
|
|
1118
|
+
case "up":
|
|
1119
|
+
return { cx: 0, cy: -len / 2, halfX: thick / 2, halfY: len / 2 };
|
|
1120
|
+
case "down":
|
|
1121
|
+
return { cx: 0, cy: len / 2, halfX: thick / 2, halfY: len / 2 };
|
|
1122
|
+
default:
|
|
1123
|
+
return { cx: len / 2, cy: 0, halfX: len / 2, halfY: thick / 2 };
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
var DEFAULT_POLYGON_SIDES = 6;
|
|
1127
|
+
var STROKE_SAMPLES = 24;
|
|
1128
|
+
function polygonPoints(s, radius) {
|
|
1129
|
+
if (s.points && s.points.length >= 3) {
|
|
1130
|
+
return s.points.map(([x, y]) => ({ x: x * radius, y: y * radius }));
|
|
1131
|
+
}
|
|
1132
|
+
const n = Math.max(3, Math.round(s.sides ?? DEFAULT_POLYGON_SIDES));
|
|
1133
|
+
const out = [];
|
|
1134
|
+
for (let k = 0; k < n; k++) {
|
|
1135
|
+
const a = -Math.PI / 2 + 2 * Math.PI * k / n;
|
|
1136
|
+
out.push({ x: Math.cos(a) * radius, y: Math.sin(a) * radius });
|
|
1137
|
+
}
|
|
1138
|
+
return out;
|
|
1139
|
+
}
|
|
1140
|
+
function polygonArea(pts) {
|
|
1141
|
+
let a = 0;
|
|
1142
|
+
for (let i = 0; i < pts.length; i++) {
|
|
1143
|
+
const p = pts[i];
|
|
1144
|
+
const q = pts[(i + 1) % pts.length];
|
|
1145
|
+
a += p.x * q.y - q.x * p.y;
|
|
1146
|
+
}
|
|
1147
|
+
return Math.abs(a) / 2;
|
|
1148
|
+
}
|
|
1149
|
+
function polygonFill(s, radius) {
|
|
1150
|
+
if (radius <= 0) return 0;
|
|
1151
|
+
const disc = Math.PI * radius * radius;
|
|
1152
|
+
return Math.min(1, polygonArea(polygonPoints(s, radius)) / disc);
|
|
1153
|
+
}
|
|
1154
|
+
function catmullRom(p0, p1, p2, p3, u) {
|
|
1155
|
+
const u2 = u * u;
|
|
1156
|
+
const u3 = u2 * u;
|
|
1157
|
+
return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
|
|
1158
|
+
}
|
|
1159
|
+
function strokeSamples(s, halfSize, n) {
|
|
1160
|
+
const pts = s.points.map(([x, y]) => ({ x: x * halfSize, y: y * halfSize }));
|
|
1161
|
+
const m = pts.length;
|
|
1162
|
+
if (m < 2) return pts;
|
|
1163
|
+
const smooth = s.curve !== "linear" && m >= 3;
|
|
1164
|
+
const segs = m - 1;
|
|
1165
|
+
n = n ?? Math.max(STROKE_SAMPLES, segs * 6 + 1);
|
|
1166
|
+
const at = (k) => pts[Math.max(0, Math.min(m - 1, k))];
|
|
1167
|
+
const out = [];
|
|
1168
|
+
for (let i = 0; i < n; i++) {
|
|
1169
|
+
const u = i / (n - 1) * segs;
|
|
1170
|
+
const seg = Math.min(segs - 1, Math.floor(u));
|
|
1171
|
+
const local = u - seg;
|
|
1172
|
+
const p1 = at(seg);
|
|
1173
|
+
const p2 = at(seg + 1);
|
|
1174
|
+
if (!smooth) {
|
|
1175
|
+
out.push({ x: p1.x + (p2.x - p1.x) * local, y: p1.y + (p2.y - p1.y) * local });
|
|
1176
|
+
} else {
|
|
1177
|
+
const p0 = at(seg - 1);
|
|
1178
|
+
const p3 = at(seg + 2);
|
|
1179
|
+
out.push({ x: catmullRom(p0.x, p1.x, p2.x, p3.x, local), y: catmullRom(p0.y, p1.y, p2.y, p3.y, local) });
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
return out;
|
|
1183
|
+
}
|
|
1184
|
+
function pathLength(pts) {
|
|
1185
|
+
let l = 0;
|
|
1186
|
+
for (let i = 1; i < pts.length; i++) {
|
|
1187
|
+
const dx = pts[i].x - pts[i - 1].x;
|
|
1188
|
+
const dy = pts[i].y - pts[i - 1].y;
|
|
1189
|
+
l += Math.sqrt(dx * dx + dy * dy);
|
|
1190
|
+
}
|
|
1191
|
+
return l;
|
|
1192
|
+
}
|
|
1193
|
+
function strokeTaper(u) {
|
|
1194
|
+
return Math.max(0.15, Math.sin(Math.PI * Math.max(0, Math.min(1, u))));
|
|
1195
|
+
}
|
|
1196
|
+
function strokeWidthPx(s, scale) {
|
|
1197
|
+
return Math.max(0.5, (s.width ?? (scale === 1 ? 2 : 2e-3)) * scale);
|
|
1198
|
+
}
|
|
1199
|
+
function featherAlphas(steps, additive) {
|
|
1200
|
+
const out = [];
|
|
1201
|
+
for (let k = 1; k <= steps; k++) out.push(additive ? 1 / steps : 1 / (steps - k + 1));
|
|
1202
|
+
return out;
|
|
1203
|
+
}
|
|
1204
|
+
var FEATHER_STEPS = 6;
|
|
1205
|
+
|
|
910
1206
|
// src/simulate.ts
|
|
911
1207
|
var WARP_NEAR = 0.08;
|
|
912
1208
|
var WARP_MAX_SCALE = 8;
|
|
@@ -935,6 +1231,21 @@ function drawOsc(rng, amp) {
|
|
|
935
1231
|
}
|
|
936
1232
|
return osc;
|
|
937
1233
|
}
|
|
1234
|
+
var GOLDEN = 0.6180339887498949;
|
|
1235
|
+
function emitParams(emit, i, n) {
|
|
1236
|
+
const every = emit.every;
|
|
1237
|
+
const jitter = Math.max(0, Math.min(1, emit.jitter ?? 1));
|
|
1238
|
+
const scattered = (i + 0.5) * GOLDEN % 1 * every;
|
|
1239
|
+
const even = i / Math.max(1, n) * every;
|
|
1240
|
+
const phase = ((even * (1 - jitter) + scattered * jitter) % every + every) % every;
|
|
1241
|
+
return {
|
|
1242
|
+
every,
|
|
1243
|
+
life: Math.min(emit.life, every),
|
|
1244
|
+
phase,
|
|
1245
|
+
growFrom: emit.grow?.[0] ?? 1,
|
|
1246
|
+
growTo: emit.grow?.[1] ?? 1
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
938
1249
|
function weightedIndex(u, weights) {
|
|
939
1250
|
const total = weights.reduce((a, b) => a + b, 0);
|
|
940
1251
|
let acc = 0;
|
|
@@ -945,15 +1256,16 @@ function weightedIndex(u, weights) {
|
|
|
945
1256
|
return weights.length - 1;
|
|
946
1257
|
}
|
|
947
1258
|
function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
948
|
-
const effectiveCount = countScale === 1 || layer.layout
|
|
1259
|
+
const effectiveCount = countScale === 1 || layer.layout !== void 0 ? layer.count : Math.max(1, Math.min(
|
|
949
1260
|
Math.round(layer.count * countScale),
|
|
950
1261
|
layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
|
|
951
1262
|
));
|
|
952
|
-
const [
|
|
1263
|
+
const defaultSize = scale === 1 ? [20, 40] : [20 / LIMITS.referenceViewport, 40 / LIMITS.referenceViewport];
|
|
1264
|
+
const [smin, smax] = layer.size ?? defaultSize;
|
|
953
1265
|
const variants = spriteVariants(layer.sprite);
|
|
954
1266
|
const sprite = layer.sprite;
|
|
955
|
-
const spriteColors = sprite
|
|
956
|
-
const colorWeights = sprite
|
|
1267
|
+
const spriteColors = isShapedSprite(sprite) ? sprite.colors : void 0;
|
|
1268
|
+
const colorWeights = isShapedSprite(sprite) ? sprite.colorWeights : void 0;
|
|
957
1269
|
const colorsLen = spriteColors?.length ?? 0;
|
|
958
1270
|
const cycle = sprite.kind === "emoji" || sprite.kind === "text" ? sprite.cycle : void 0;
|
|
959
1271
|
const mSpec = layer.motion;
|
|
@@ -967,7 +1279,32 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
967
1279
|
if (mSpec.type === "path") {
|
|
968
1280
|
pathPts = mSpec.points.map((p) => ({ x: p.x * w, y: p.y * h }));
|
|
969
1281
|
}
|
|
1282
|
+
const ease = mSpec.type === "drift" || mSpec.type === "rise" || mSpec.type === "wander" ? mSpec.ease : void 0;
|
|
1283
|
+
const clockPhase = layer.clock ? (layer.clock.phase ?? 0) % 1 * Math.PI * 2 : 0;
|
|
970
1284
|
const layout = layer.layout;
|
|
1285
|
+
const ordered = layout?.type === "list" || layout?.type === "table";
|
|
1286
|
+
let gapX = 0;
|
|
1287
|
+
let gapY = 0;
|
|
1288
|
+
let listX0 = 0;
|
|
1289
|
+
let listY0 = 0;
|
|
1290
|
+
let listCols = 1;
|
|
1291
|
+
if (layout?.type === "list" || layout?.type === "table") {
|
|
1292
|
+
const dflt = scale === 1 ? LIMITS.defaultListGapPx : LIMITS.defaultListGap;
|
|
1293
|
+
const g = layout.gap ?? dflt;
|
|
1294
|
+
gapX = (typeof g === "number" ? g : g.x ?? dflt) * scale;
|
|
1295
|
+
gapY = (typeof g === "number" ? g : g.y ?? dflt) * scale;
|
|
1296
|
+
listCols = layout.type === "list" ? 1 : Math.max(1, Math.round(layout.columns));
|
|
1297
|
+
const rows = Math.max(1, Math.ceil(effectiveCount / listCols));
|
|
1298
|
+
if (layer.position) {
|
|
1299
|
+
listX0 = layer.position.x * w;
|
|
1300
|
+
listY0 = layer.position.y * h;
|
|
1301
|
+
} else {
|
|
1302
|
+
const [rx0, rx1] = layer.region?.x ?? [0, 1];
|
|
1303
|
+
const [ry0, ry1] = layer.region?.y ?? [0, 1];
|
|
1304
|
+
listX0 = (rx0 + rx1) / 2 * w - (listCols - 1) * gapX / 2;
|
|
1305
|
+
listY0 = (ry0 + ry1) / 2 * h - (rows - 1) * gapY / 2;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
971
1308
|
let gridCols = 0;
|
|
972
1309
|
let cellW = 0;
|
|
973
1310
|
let cellH = 0;
|
|
@@ -990,8 +1327,8 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
990
1327
|
}
|
|
991
1328
|
const out = [];
|
|
992
1329
|
for (let i = 0; i < effectiveCount; i++) {
|
|
993
|
-
const size = sprite.kind === "textBlock" ? sprite.fontSize * scale : sprite.kind === "circle" || sprite.kind === "ring" ? rng.range(sprite.radius[0], sprite.radius[1]) * 2 * scale : sprite.kind === "streak" ? rng.range(sprite.length[0], sprite.length[1]) * scale : sprite.kind === "rect" ? rng.range(sprite.width[0], sprite.width[1]) * scale : rng.range(smin, smax) * scale;
|
|
994
|
-
const size2 = sprite.kind === "rect" ? size * (sprite.aspect ? rng.range(sprite.aspect[0], sprite.aspect[1]) : 1) : void 0;
|
|
1330
|
+
const size = sprite.kind === "textBlock" ? sprite.fontSize * scale : sprite.kind === "circle" || sprite.kind === "ring" || sprite.kind === "polygon" ? rng.range(sprite.radius[0], sprite.radius[1]) * 2 * scale : sprite.kind === "streak" || sprite.kind === "stroke" ? rng.range(sprite.length[0], sprite.length[1]) * scale : sprite.kind === "rect" ? rng.range(sprite.width[0], sprite.width[1]) * scale : sprite.kind === "bar" ? sprite.length * scale : rng.range(smin, smax) * scale;
|
|
1331
|
+
const size2 = sprite.kind === "rect" ? size * (sprite.aspect ? rng.range(sprite.aspect[0], sprite.aspect[1]) : 1) : sprite.kind === "bar" ? sprite.thickness * scale : void 0;
|
|
995
1332
|
let vx = 0;
|
|
996
1333
|
let vy = 0;
|
|
997
1334
|
let bob = 0;
|
|
@@ -1055,6 +1392,11 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
1055
1392
|
if (layer.position && layer.count === 1) {
|
|
1056
1393
|
x0 = layer.position.x * w;
|
|
1057
1394
|
y0 = layer.position.y * h;
|
|
1395
|
+
} else if (layout?.type === "list" || layout?.type === "table") {
|
|
1396
|
+
rng.next();
|
|
1397
|
+
rng.next();
|
|
1398
|
+
x0 = listX0 + i % listCols * gapX;
|
|
1399
|
+
y0 = listY0 + Math.floor(i / listCols) * gapY;
|
|
1058
1400
|
} else if (layout?.type === "grid") {
|
|
1059
1401
|
const col = i % gridCols;
|
|
1060
1402
|
const row = Math.floor(i / gridCols);
|
|
@@ -1074,7 +1416,10 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
1074
1416
|
x0,
|
|
1075
1417
|
y0,
|
|
1076
1418
|
size,
|
|
1077
|
-
|
|
1419
|
+
// Data layouts read variants in order — strings[i] beside bar i. The
|
|
1420
|
+
// seeded draw is still consumed so a layout is placement only: toggling
|
|
1421
|
+
// list/table never disturbs the layer's alpha, pulse or spin stream.
|
|
1422
|
+
spriteIndex: variants > 1 ? ordered ? (rng.int(0, variants - 1), i % variants) : rng.int(0, variants - 1) : 0,
|
|
1078
1423
|
phase: rng.range(0, Math.PI * 2),
|
|
1079
1424
|
vx,
|
|
1080
1425
|
vy,
|
|
@@ -1098,11 +1443,14 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
1098
1443
|
orbitCx: m.type === "orbit" && !orbitParent ? (m.center?.x ?? 0.5) * w : 0,
|
|
1099
1444
|
orbitCy: m.type === "orbit" && !orbitParent ? (m.center?.y ?? 0.5) * h : 0,
|
|
1100
1445
|
// New draws AFTER orbit — guarded so existing specs keep identical streams.
|
|
1101
|
-
colorIndex: colorsLen > 0 ? colorWeights && colorWeights.length === colorsLen ? weightedIndex(rng.next(), colorWeights) : rng.int(0, colorsLen - 1) : -1,
|
|
1446
|
+
colorIndex: colorsLen > 0 ? colorWeights && colorWeights.length === colorsLen ? weightedIndex(rng.next(), colorWeights) : ordered ? (rng.int(0, colorsLen - 1), i % colorsLen) : rng.int(0, colorsLen - 1) : -1,
|
|
1102
1447
|
cyclePeriod: cycle ? cycle.period : 0,
|
|
1103
1448
|
// Optional feature fields last (guarded draws inside — wander draws 18 here).
|
|
1104
1449
|
...size2 !== void 0 ? { size2 } : {},
|
|
1450
|
+
...sprite.kind === "bar" ? { barIndex: i } : {},
|
|
1451
|
+
...ordered ? { ordered: true } : {},
|
|
1105
1452
|
...orbitParent ? { orbitParent } : {},
|
|
1453
|
+
...layer.wrap === false ? { wrapDisabled: true } : {},
|
|
1106
1454
|
...m.type === "wander" ? {
|
|
1107
1455
|
wander: {
|
|
1108
1456
|
own: drawOsc(rng, meanderPx),
|
|
@@ -1122,33 +1470,80 @@ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
|
|
|
1122
1470
|
offX: pathOffX,
|
|
1123
1471
|
offY: pathOffY
|
|
1124
1472
|
}
|
|
1125
|
-
} : {}
|
|
1473
|
+
} : {},
|
|
1474
|
+
// Time-structure fields (2026-09): ease draws nothing; emit's one draw
|
|
1475
|
+
// per entity happens in a pass AFTER this loop (see below).
|
|
1476
|
+
...ease ? { ease: { type: ease.type, tau: ease.tau } } : {}
|
|
1126
1477
|
});
|
|
1478
|
+
if (layer.clock) {
|
|
1479
|
+
const ent = out[out.length - 1];
|
|
1480
|
+
ent.pulsePhase = clockPhase;
|
|
1481
|
+
ent.growPhase = clockPhase;
|
|
1482
|
+
ent.cyclePhase = clockPhase;
|
|
1483
|
+
ent.clockRate = layer.clock.rate ?? 1;
|
|
1484
|
+
}
|
|
1127
1485
|
if (layer.pulse?.wave) {
|
|
1128
1486
|
const wl = Math.max(1e-6, layer.pulse.wave.wavelength * scale);
|
|
1129
1487
|
const ang = (layer.pulse.wave.angle ?? 0) * Math.PI / 180;
|
|
1130
|
-
out[out.length - 1].pulsePhase = -(
|
|
1488
|
+
out[out.length - 1].pulsePhase = clockPhase - (x0 * Math.cos(ang) + y0 * Math.sin(ang)) / wl * Math.PI * 2;
|
|
1131
1489
|
}
|
|
1132
1490
|
}
|
|
1491
|
+
if (layer.emit) {
|
|
1492
|
+
for (let i = 0; i < out.length; i++) out[i].emit = emitParams(layer.emit, i, out.length);
|
|
1493
|
+
}
|
|
1133
1494
|
return out;
|
|
1134
1495
|
}
|
|
1135
1496
|
function warpZ(e, t) {
|
|
1136
1497
|
const wp = e.warp;
|
|
1137
1498
|
return wrap(wp.z0 - wp.vz * (t / 1e3), WARP_NEAR, 1);
|
|
1138
1499
|
}
|
|
1500
|
+
function smoothstep(x) {
|
|
1501
|
+
const c = x < 0 ? 0 : x > 1 ? 1 : x;
|
|
1502
|
+
return c * c * (3 - 2 * c);
|
|
1503
|
+
}
|
|
1504
|
+
var EMIT_ATTACK = 0.25;
|
|
1505
|
+
function emitWindow(e, t) {
|
|
1506
|
+
const em = e.emit;
|
|
1507
|
+
if (!em) return null;
|
|
1508
|
+
const local = ((t - em.phase) % em.every + em.every) % em.every;
|
|
1509
|
+
return local < em.life ? local / em.life : null;
|
|
1510
|
+
}
|
|
1511
|
+
function emitEnvelope(u) {
|
|
1512
|
+
if (u < 0 || u >= 1) return 0;
|
|
1513
|
+
return u < EMIT_ATTACK ? smoothstep(u / EMIT_ATTACK) : 1 - smoothstep((u - EMIT_ATTACK) / (1 - EMIT_ATTACK));
|
|
1514
|
+
}
|
|
1515
|
+
function clockTime(e, t) {
|
|
1516
|
+
return e.clockRate ? t * e.clockRate : t;
|
|
1517
|
+
}
|
|
1518
|
+
function motionSeconds(e, t) {
|
|
1519
|
+
if (!e.ease) return t / 1e3;
|
|
1520
|
+
let tl = t;
|
|
1521
|
+
if (e.emit) tl = ((t - e.emit.phase) % e.emit.every + e.emit.every) % e.emit.every;
|
|
1522
|
+
const tau = e.ease.tau;
|
|
1523
|
+
const k = 1 - Math.exp(-tl / tau);
|
|
1524
|
+
return (e.ease.type === "settle" ? tau * k : tl - tau * k) / 1e3;
|
|
1525
|
+
}
|
|
1139
1526
|
function alphaAt(e, t) {
|
|
1140
1527
|
let a = e.alpha;
|
|
1141
|
-
if (e.pulseAmp) a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
|
|
1528
|
+
if (e.pulseAmp) a = e.alpha + e.pulseAmp * Math.sin(clockTime(e, t) * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
|
|
1142
1529
|
if (e.motion === "warp") {
|
|
1143
1530
|
const z = warpZ(e, t);
|
|
1144
1531
|
a *= Math.max(0, Math.min(1, (1 - z) / 0.2));
|
|
1145
1532
|
}
|
|
1533
|
+
if (e.emit) {
|
|
1534
|
+
const u = emitWindow(e, t);
|
|
1535
|
+
a = u === null ? 0 : a * emitEnvelope(u);
|
|
1536
|
+
}
|
|
1146
1537
|
return a < 0 ? 0 : a > 1 ? 1 : a;
|
|
1147
1538
|
}
|
|
1148
1539
|
function sizeAt(e, t) {
|
|
1149
1540
|
let s = e.size;
|
|
1150
|
-
if (e.growAmp) s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
|
|
1541
|
+
if (e.growAmp) s = e.size * (1 + e.growAmp * Math.sin(clockTime(e, t) * 2 * Math.PI / e.growPeriod + e.growPhase));
|
|
1151
1542
|
if (e.motion === "warp") s *= Math.min(1 / warpZ(e, t), WARP_MAX_SCALE);
|
|
1543
|
+
if (e.emit && (e.emit.growFrom !== 1 || e.emit.growTo !== 1)) {
|
|
1544
|
+
const u = emitWindow(e, t);
|
|
1545
|
+
if (u !== null) s *= e.emit.growFrom + (e.emit.growTo - e.emit.growFrom) * u;
|
|
1546
|
+
}
|
|
1152
1547
|
return s > 0 ? s : 0.1;
|
|
1153
1548
|
}
|
|
1154
1549
|
function rotationAt(e, t) {
|
|
@@ -1179,7 +1574,7 @@ function reflect(v, min, max) {
|
|
|
1179
1574
|
const p = ((v - min) % period + period) % period;
|
|
1180
1575
|
return min + (p < range ? p : period - p);
|
|
1181
1576
|
}
|
|
1182
|
-
function
|
|
1577
|
+
function catmullRom2(p0, p1, p2, p3, u) {
|
|
1183
1578
|
const u2 = u * u;
|
|
1184
1579
|
const u3 = u2 * u;
|
|
1185
1580
|
return 0.5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u2 + (-p0 + 3 * p1 - 3 * p2 + p3) * u3);
|
|
@@ -1209,8 +1604,8 @@ function pathPosition(e, t) {
|
|
|
1209
1604
|
const p0 = at(i - 1);
|
|
1210
1605
|
const p3 = at(i + 2);
|
|
1211
1606
|
return {
|
|
1212
|
-
x:
|
|
1213
|
-
y:
|
|
1607
|
+
x: catmullRom2(p0.x, p1.x, p2.x, p3.x, local) + p.offX,
|
|
1608
|
+
y: catmullRom2(p0.y, p1.y, p2.y, p3.y, local) + p.offY
|
|
1214
1609
|
};
|
|
1215
1610
|
}
|
|
1216
1611
|
function positionAt(e, t, w, h) {
|
|
@@ -1237,7 +1632,7 @@ function positionAt(e, t, w, h) {
|
|
|
1237
1632
|
const pos = pathPosition(e, t);
|
|
1238
1633
|
return { x: pos.x, y: pos.y, flip: false };
|
|
1239
1634
|
}
|
|
1240
|
-
const dt = t
|
|
1635
|
+
const dt = motionSeconds(e, t);
|
|
1241
1636
|
const m = e.size;
|
|
1242
1637
|
if (e.motion === "bounce") {
|
|
1243
1638
|
return {
|
|
@@ -1247,9 +1642,10 @@ function positionAt(e, t, w, h) {
|
|
|
1247
1642
|
};
|
|
1248
1643
|
}
|
|
1249
1644
|
if (e.motion === "rise") {
|
|
1645
|
+
const y2 = e.y0 + e.vy * dt;
|
|
1250
1646
|
return {
|
|
1251
1647
|
x: e.x0 + (e.bob ? e.bob * Math.sin(t / 700 + e.phase) : 0),
|
|
1252
|
-
y:
|
|
1648
|
+
y: e.wrapDisabled ? y2 : wrap(y2, -m, h + m),
|
|
1253
1649
|
flip: false
|
|
1254
1650
|
};
|
|
1255
1651
|
}
|
|
@@ -1269,14 +1665,18 @@ function positionAt(e, t, w, h) {
|
|
|
1269
1665
|
}
|
|
1270
1666
|
}
|
|
1271
1667
|
const margin = m + wp.margin;
|
|
1668
|
+
const x2 = e.x0 + e.vx * dt + hx;
|
|
1669
|
+
const y2 = e.y0 + e.vy * dt + hy;
|
|
1272
1670
|
return {
|
|
1273
|
-
x:
|
|
1274
|
-
y:
|
|
1671
|
+
x: e.wrapDisabled ? x2 : wrap(x2, -margin, w + margin),
|
|
1672
|
+
y: e.wrapDisabled ? y2 : wrap(y2, -margin, h + margin),
|
|
1275
1673
|
flip: e.headingLeft
|
|
1276
1674
|
};
|
|
1277
1675
|
}
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1676
|
+
const rawX = e.x0 + e.vx * dt;
|
|
1677
|
+
const x = e.wrapDisabled ? rawX : wrap(rawX, -m, w + m);
|
|
1678
|
+
const rawY = e.y0 + e.vy * dt;
|
|
1679
|
+
let y = e.vy !== 0 && !e.wrapDisabled ? wrap(rawY, -m, h + m) : rawY;
|
|
1280
1680
|
if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
|
|
1281
1681
|
return { x, y, flip: e.headingLeft };
|
|
1282
1682
|
}
|
|
@@ -1294,7 +1694,8 @@ function headingAt(e, t, w, h) {
|
|
|
1294
1694
|
}
|
|
1295
1695
|
function spriteIndexAt(e, t, variants) {
|
|
1296
1696
|
if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
|
|
1297
|
-
|
|
1697
|
+
if (e.ordered) return (e.spriteIndex + Math.floor(clockTime(e, t) / e.cyclePeriod)) % variants;
|
|
1698
|
+
return Math.floor(clockTime(e, t) / e.cyclePeriod + (e.cyclePhase ?? e.phase) / (2 * Math.PI)) % variants;
|
|
1298
1699
|
}
|
|
1299
1700
|
function wrapDist(pi, pj, doWrap, w, h) {
|
|
1300
1701
|
let dx = pj.x - pi.x;
|
|
@@ -1756,7 +2157,7 @@ var SpecInstance = class {
|
|
|
1756
2157
|
const headAlpha = alphaAt(e, t) * lifeA;
|
|
1757
2158
|
const headSize = sizeAt(e, t);
|
|
1758
2159
|
const sprite = built.layer.sprite;
|
|
1759
|
-
const resolvedColor = sprite
|
|
2160
|
+
const resolvedColor = isShapedSprite(sprite) ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" || sprite.kind === "textBlock" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
|
|
1760
2161
|
const isSoft = sprite.kind === "circle" && sprite.soft;
|
|
1761
2162
|
const wrap2 = built.layer.wrap !== false;
|
|
1762
2163
|
const head = this.entityPos(e, t, parentE);
|
|
@@ -1835,7 +2236,90 @@ var SpecInstance = class {
|
|
|
1835
2236
|
ctx.translate(p.x, p.y);
|
|
1836
2237
|
if (rot) ctx.rotate(rot);
|
|
1837
2238
|
ctx.fillStyle = resolvedColor;
|
|
1838
|
-
|
|
2239
|
+
const feather = sprite.feather ?? 0;
|
|
2240
|
+
if (feather > 0) {
|
|
2241
|
+
const base = ctx.globalAlpha;
|
|
2242
|
+
const additive = built.layer.blend === "lighter";
|
|
2243
|
+
const alphas = featherAlphas(FEATHER_STEPS, additive);
|
|
2244
|
+
for (let j = 1; j <= FEATHER_STEPS; j++) {
|
|
2245
|
+
const f = 1 - feather * (j - 1) / FEATHER_STEPS;
|
|
2246
|
+
const fw = sz * f;
|
|
2247
|
+
const fh = rh * f;
|
|
2248
|
+
ctx.globalAlpha = base * alphas[j - 1];
|
|
2249
|
+
ctx.fillRect(-fw / 2, -fh / 2, fw, fh);
|
|
2250
|
+
}
|
|
2251
|
+
ctx.globalAlpha = base;
|
|
2252
|
+
} else {
|
|
2253
|
+
ctx.fillRect(-sz / 2, -rh / 2, sz, rh);
|
|
2254
|
+
}
|
|
2255
|
+
ctx.restore();
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
2258
|
+
if (sprite.kind === "bar") {
|
|
2259
|
+
const len = sz * barFraction(sprite, e.barIndex ?? 0);
|
|
2260
|
+
if (len <= 0) return;
|
|
2261
|
+
const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
|
|
2262
|
+
const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
|
|
2263
|
+
const box = barBox(sprite.direction ?? "right", len, th);
|
|
2264
|
+
ctx.save();
|
|
2265
|
+
ctx.translate(p.x, p.y);
|
|
2266
|
+
if (rot) ctx.rotate(rot);
|
|
2267
|
+
ctx.fillStyle = resolvedColor;
|
|
2268
|
+
ctx.fillRect(box.cx - box.halfX, box.cy - box.halfY, box.halfX * 2, box.halfY * 2);
|
|
2269
|
+
ctx.restore();
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
if (sprite.kind === "polygon") {
|
|
2273
|
+
const r = sz / 2;
|
|
2274
|
+
const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
|
|
2275
|
+
const pts = polygonPoints(sprite, r);
|
|
2276
|
+
ctx.save();
|
|
2277
|
+
ctx.translate(p.x, p.y);
|
|
2278
|
+
if (rot) ctx.rotate(rot);
|
|
2279
|
+
if (sprite.soft) {
|
|
2280
|
+
const g = ctx.createRadialGradient(0, 0, 0, 0, 0, r);
|
|
2281
|
+
g.addColorStop(0, resolvedColor);
|
|
2282
|
+
g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
|
|
2283
|
+
g.addColorStop(1, hexToRgba(resolvedColor, 0));
|
|
2284
|
+
ctx.fillStyle = g;
|
|
2285
|
+
} else {
|
|
2286
|
+
ctx.fillStyle = resolvedColor;
|
|
2287
|
+
}
|
|
2288
|
+
ctx.beginPath();
|
|
2289
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
2290
|
+
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
2291
|
+
ctx.closePath();
|
|
2292
|
+
ctx.fill();
|
|
2293
|
+
ctx.restore();
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
if (sprite.kind === "stroke") {
|
|
2297
|
+
const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
|
|
2298
|
+
const pts = strokeSamples(sprite, sz / 2);
|
|
2299
|
+
const lw = strokeWidthPx(sprite, unitScale);
|
|
2300
|
+
ctx.save();
|
|
2301
|
+
ctx.translate(p.x, p.y);
|
|
2302
|
+
let angle = rot;
|
|
2303
|
+
if (sprite.orient) angle += headingAt(e, t, this.w, this.h) ?? 0;
|
|
2304
|
+
if (angle) ctx.rotate(angle);
|
|
2305
|
+
ctx.strokeStyle = resolvedColor;
|
|
2306
|
+
ctx.lineCap = "round";
|
|
2307
|
+
ctx.lineJoin = "round";
|
|
2308
|
+
if (sprite.taper) {
|
|
2309
|
+
for (let i = 1; i < pts.length; i++) {
|
|
2310
|
+
ctx.lineWidth = Math.max(0.5, lw * strokeTaper((i - 0.5) / (pts.length - 1)));
|
|
2311
|
+
ctx.beginPath();
|
|
2312
|
+
ctx.moveTo(pts[i - 1].x, pts[i - 1].y);
|
|
2313
|
+
ctx.lineTo(pts[i].x, pts[i].y);
|
|
2314
|
+
ctx.stroke();
|
|
2315
|
+
}
|
|
2316
|
+
} else {
|
|
2317
|
+
ctx.lineWidth = lw;
|
|
2318
|
+
ctx.beginPath();
|
|
2319
|
+
ctx.moveTo(pts[0].x, pts[0].y);
|
|
2320
|
+
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
|
|
2321
|
+
ctx.stroke();
|
|
2322
|
+
}
|
|
1839
2323
|
ctx.restore();
|
|
1840
2324
|
return;
|
|
1841
2325
|
}
|
|
@@ -2000,7 +2484,9 @@ var SpecInstance = class {
|
|
|
2000
2484
|
const positions = built.entities.map((e) => this.entityPos(e, t, parentE));
|
|
2001
2485
|
const maxDistPx = links.maxDist * (this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h));
|
|
2002
2486
|
const edges = linkEdges(links, positions, maxDistPx, wrap2, this.w, this.h);
|
|
2003
|
-
const
|
|
2487
|
+
const unitScale = this.effSpec.units === "px" ? 1 : Math.min(this.w, this.h);
|
|
2488
|
+
const defaultWidth = unitScale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
2489
|
+
const lw = (links.width ?? defaultWidth) * unitScale;
|
|
2004
2490
|
ctx.lineWidth = lw;
|
|
2005
2491
|
ctx.lineCap = "butt";
|
|
2006
2492
|
for (const { i, j, dist } of edges) {
|
|
@@ -2010,7 +2496,7 @@ var SpecInstance = class {
|
|
|
2010
2496
|
let resolvedColor = links.color;
|
|
2011
2497
|
if (!resolvedColor) {
|
|
2012
2498
|
const sprite = built.layer.sprite;
|
|
2013
|
-
if (sprite
|
|
2499
|
+
if (isShapedSprite(sprite)) {
|
|
2014
2500
|
resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
|
|
2015
2501
|
} else resolvedColor = "#e6e8ef";
|
|
2016
2502
|
}
|
|
@@ -2286,7 +2772,6 @@ var SequenceInstance = class {
|
|
|
2286
2772
|
child.hotSwapSpec(this.childScene(index));
|
|
2287
2773
|
} else {
|
|
2288
2774
|
child = this.ensureChild(index);
|
|
2289
|
-
if (useMorphSeed) child.hotSwapSpec(this.childScene(index));
|
|
2290
2775
|
}
|
|
2291
2776
|
child.renderFrame(localT, effSeed);
|
|
2292
2777
|
}
|
|
@@ -2373,7 +2858,7 @@ function hexLuma(hex) {
|
|
|
2373
2858
|
}
|
|
2374
2859
|
function spriteLuma(layer, e) {
|
|
2375
2860
|
const s = layer.sprite;
|
|
2376
|
-
if (s
|
|
2861
|
+
if (isShapedSprite(s)) {
|
|
2377
2862
|
return hexLuma(s.colors?.[e.colorIndex] ?? s.color);
|
|
2378
2863
|
}
|
|
2379
2864
|
if (s.kind === "text" || s.kind === "textBlock") return hexLuma(s.color ?? "#e6e8ef");
|
|
@@ -2381,7 +2866,7 @@ function spriteLuma(layer, e) {
|
|
|
2381
2866
|
}
|
|
2382
2867
|
function spriteHex(layer, e) {
|
|
2383
2868
|
const s = layer.sprite;
|
|
2384
|
-
if (s
|
|
2869
|
+
if (isShapedSprite(s)) {
|
|
2385
2870
|
return s.colors?.[e.colorIndex] ?? s.color;
|
|
2386
2871
|
}
|
|
2387
2872
|
if (s.kind === "text" || s.kind === "textBlock") return s.color ?? "#e6e8ef";
|
|
@@ -2419,19 +2904,237 @@ function colourSeparation(layer, sprite, bg) {
|
|
|
2419
2904
|
return Math.sqrt((dr * dr + dg * dg + db * db) / 3);
|
|
2420
2905
|
}
|
|
2421
2906
|
|
|
2907
|
+
// src/cohesion.ts
|
|
2908
|
+
var MERGE_FLOOR = 0.35;
|
|
2909
|
+
var WASH_ALPHA_MAX = 0.45;
|
|
2910
|
+
var COHESION_T = 5e3;
|
|
2911
|
+
var OUTLINE_SAMPLES = 24;
|
|
2912
|
+
var MAX_TRACED = 240;
|
|
2913
|
+
var EDGE_INSET = 1e-4;
|
|
2914
|
+
function circleShape(cx, cy, r) {
|
|
2915
|
+
return {
|
|
2916
|
+
cx,
|
|
2917
|
+
cy,
|
|
2918
|
+
br: r,
|
|
2919
|
+
area: Math.PI * r * r,
|
|
2920
|
+
contains: (x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) < r * r,
|
|
2921
|
+
outline: (n) => Array.from({ length: n }, (_, i) => {
|
|
2922
|
+
const a = i / n * Math.PI * 2;
|
|
2923
|
+
const inner = r * (1 - EDGE_INSET);
|
|
2924
|
+
return { x: cx + inner * Math.cos(a), y: cy + inner * Math.sin(a) };
|
|
2925
|
+
})
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
function polyShape(cx, cy, raw) {
|
|
2929
|
+
const abs = raw.filter((p, i) => {
|
|
2930
|
+
const q = raw[(i + 1) % raw.length];
|
|
2931
|
+
return Math.hypot(q.x - p.x, q.y - p.y) > 0;
|
|
2932
|
+
});
|
|
2933
|
+
if (abs.length < 3) return null;
|
|
2934
|
+
let br = 0;
|
|
2935
|
+
for (const p of abs) br = Math.max(br, Math.hypot(p.x - cx, p.y - cy));
|
|
2936
|
+
const inside = (x, y) => {
|
|
2937
|
+
let hit = false;
|
|
2938
|
+
for (let i = 0, j = abs.length - 1; i < abs.length; j = i++) {
|
|
2939
|
+
const a = abs[i];
|
|
2940
|
+
const b = abs[j];
|
|
2941
|
+
if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) hit = !hit;
|
|
2942
|
+
}
|
|
2943
|
+
return hit;
|
|
2944
|
+
};
|
|
2945
|
+
const seg = abs.map((p, i) => {
|
|
2946
|
+
const q = abs[(i + 1) % abs.length];
|
|
2947
|
+
return Math.hypot(q.x - p.x, q.y - p.y);
|
|
2948
|
+
});
|
|
2949
|
+
const perimeter = seg.reduce((a, b) => a + b, 0);
|
|
2950
|
+
const shortest = Math.min(...seg);
|
|
2951
|
+
const step = Math.min(br * EDGE_INSET, shortest * 0.25);
|
|
2952
|
+
const area = polygonArea(abs);
|
|
2953
|
+
if (!(area > 0)) return null;
|
|
2954
|
+
return {
|
|
2955
|
+
cx,
|
|
2956
|
+
cy,
|
|
2957
|
+
br,
|
|
2958
|
+
area,
|
|
2959
|
+
contains: inside,
|
|
2960
|
+
/**
|
|
2961
|
+
* `n` points spread along the perimeter by ARC LENGTH, not one share per
|
|
2962
|
+
* edge — a long skinny rectangle has two edges carrying almost all of its
|
|
2963
|
+
* perimeter, and an equal-per-edge split would report a fully buried long
|
|
2964
|
+
* side as only a quarter covered. Each point is then stepped along its
|
|
2965
|
+
* edge's inward normal, whichever of the two candidates lands inside.
|
|
2966
|
+
*/
|
|
2967
|
+
outline: (n) => {
|
|
2968
|
+
if (!(perimeter > 0)) return [];
|
|
2969
|
+
const out = [];
|
|
2970
|
+
for (let i = 0; i < n; i++) {
|
|
2971
|
+
let d = i / n * perimeter;
|
|
2972
|
+
let k = 0;
|
|
2973
|
+
while (k < seg.length - 1 && d > seg[k]) {
|
|
2974
|
+
d -= seg[k];
|
|
2975
|
+
k++;
|
|
2976
|
+
}
|
|
2977
|
+
const a = abs[k];
|
|
2978
|
+
const b = abs[(k + 1) % abs.length];
|
|
2979
|
+
const len = seg[k];
|
|
2980
|
+
const f = d / len;
|
|
2981
|
+
const px = a.x + (b.x - a.x) * f;
|
|
2982
|
+
const py = a.y + (b.y - a.y) * f;
|
|
2983
|
+
const ux = (b.x - a.x) / len;
|
|
2984
|
+
const uy = (b.y - a.y) / len;
|
|
2985
|
+
for (let t = step; t > 0; t /= 8) {
|
|
2986
|
+
const qx1 = px - uy * t;
|
|
2987
|
+
const qy1 = py + ux * t;
|
|
2988
|
+
if (inside(qx1, qy1)) {
|
|
2989
|
+
out.push({ x: qx1, y: qy1 });
|
|
2990
|
+
break;
|
|
2991
|
+
}
|
|
2992
|
+
const qx2 = px + uy * t;
|
|
2993
|
+
const qy2 = py - ux * t;
|
|
2994
|
+
if (inside(qx2, qy2)) {
|
|
2995
|
+
out.push({ x: qx2, y: qy2 });
|
|
2996
|
+
break;
|
|
2997
|
+
}
|
|
2998
|
+
if (t < step * 1e-3) break;
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
return out;
|
|
3002
|
+
}
|
|
3003
|
+
};
|
|
3004
|
+
}
|
|
3005
|
+
function rectShape(cx, cy, halfW, halfH, rot) {
|
|
3006
|
+
const cos = Math.cos(rot);
|
|
3007
|
+
const sin = Math.sin(rot);
|
|
3008
|
+
const corner = (lx, ly) => ({ x: cx + lx * cos - ly * sin, y: cy + lx * sin + ly * cos });
|
|
3009
|
+
return polyShape(cx, cy, [
|
|
3010
|
+
corner(-halfW, -halfH),
|
|
3011
|
+
corner(halfW, -halfH),
|
|
3012
|
+
corner(halfW, halfH),
|
|
3013
|
+
corner(-halfW, halfH)
|
|
3014
|
+
]);
|
|
3015
|
+
}
|
|
3016
|
+
function polygonShape(cx, cy, pts, rot) {
|
|
3017
|
+
const cos = Math.cos(rot);
|
|
3018
|
+
const sin = Math.sin(rot);
|
|
3019
|
+
return polyShape(cx, cy, pts.map((p) => ({ x: cx + p.x * cos - p.y * sin, y: cy + p.x * sin + p.y * cos })));
|
|
3020
|
+
}
|
|
3021
|
+
function shapeOf(layer, e, t, w, h) {
|
|
3022
|
+
const s = layer.sprite;
|
|
3023
|
+
if (s.kind !== "circle" && s.kind !== "rect" && s.kind !== "polygon") return null;
|
|
3024
|
+
const p = positionAt(e, t, w, h);
|
|
3025
|
+
const sz = sizeAt(e, t);
|
|
3026
|
+
if (!(sz > 0)) return null;
|
|
3027
|
+
if (s.kind === "circle") return circleShape(p.x, p.y, sz / 2);
|
|
3028
|
+
const rot = rotationAt(e, t);
|
|
3029
|
+
if (s.kind === "rect") {
|
|
3030
|
+
const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
|
|
3031
|
+
return rectShape(p.x, p.y, sz / 2, h2 / 2, rot);
|
|
3032
|
+
}
|
|
3033
|
+
return polygonShape(p.x, p.y, polygonPoints(s, sz / 2), rot);
|
|
3034
|
+
}
|
|
3035
|
+
function seamCauseOf(layer) {
|
|
3036
|
+
const s = layer.sprite;
|
|
3037
|
+
if ((s.kind === "circle" || s.kind === "polygon") && s.soft) {
|
|
3038
|
+
return "sprite.soft \u2014 radial falloff never forms a hard union";
|
|
3039
|
+
}
|
|
3040
|
+
if (s.kind === "rect" && s.feather) {
|
|
3041
|
+
return "rect.feather \u2014 a feathered edge never forms a hard union";
|
|
3042
|
+
}
|
|
3043
|
+
if (layer.blend) {
|
|
3044
|
+
return `blend: ${layer.blend} \u2014 overlaps composite instead of covering`;
|
|
3045
|
+
}
|
|
3046
|
+
const [lo, hi] = layer.alpha ?? [1, 1];
|
|
3047
|
+
if (lo < 1 || hi < 1) {
|
|
3048
|
+
return `alpha ${lo === hi ? lo : `${lo}..${hi}`} \u2014 every overlap paints a darker patch`;
|
|
3049
|
+
}
|
|
3050
|
+
if (layer.pulse && layer.pulse.amp > 0) {
|
|
3051
|
+
return "pulse \u2014 the layer is translucent for part of every cycle";
|
|
3052
|
+
}
|
|
3053
|
+
const palette = "colors" in s && Array.isArray(s.colors) ? s.colors : null;
|
|
3054
|
+
const weights = "colorWeights" in s && Array.isArray(s.colorWeights) ? s.colorWeights : null;
|
|
3055
|
+
if (palette) {
|
|
3056
|
+
const aligned = weights !== null && weights.length === palette.length;
|
|
3057
|
+
const reachable = new Set(palette.filter((_, i) => !aligned || (weights[i] ?? 0) > 0));
|
|
3058
|
+
if (reachable.size > 1) {
|
|
3059
|
+
return `sprite.colors has ${reachable.size} reachable colours \u2014 neighbours of unlike colour show their join`;
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
return null;
|
|
3063
|
+
}
|
|
3064
|
+
function cohesionOf(layers, t, w, h) {
|
|
3065
|
+
return layers.map(({ layer, entities }, layerIndex) => {
|
|
3066
|
+
const seamCause = seamCauseOf(layer);
|
|
3067
|
+
const base = {
|
|
3068
|
+
layerIndex,
|
|
3069
|
+
key: layer.key,
|
|
3070
|
+
seamless: seamCause === null,
|
|
3071
|
+
seamCause
|
|
3072
|
+
};
|
|
3073
|
+
if (lifeAlphaAt(layer.life, t) <= 0) return { ...base, overlap: null, reads: null };
|
|
3074
|
+
const shapes = entities.map((e) => shapeOf(layer, e, t, w, h));
|
|
3075
|
+
const solid = shapes.filter((x) => x !== null && x.area > 0);
|
|
3076
|
+
if (solid.length < 2) return { ...base, overlap: null, reads: null };
|
|
3077
|
+
const stride = Math.max(1, Math.ceil(solid.length / MAX_TRACED));
|
|
3078
|
+
let covered = 0;
|
|
3079
|
+
let traced = 0;
|
|
3080
|
+
for (let i = 0; i < solid.length; i += stride) {
|
|
3081
|
+
const self = solid[i];
|
|
3082
|
+
const pts = self.outline(OUTLINE_SAMPLES);
|
|
3083
|
+
if (pts.length === 0) continue;
|
|
3084
|
+
let hit = 0;
|
|
3085
|
+
for (const p of pts) {
|
|
3086
|
+
for (let j = 0; j < solid.length; j++) {
|
|
3087
|
+
if (j === i) continue;
|
|
3088
|
+
const other = solid[j];
|
|
3089
|
+
const dx = p.x - other.cx;
|
|
3090
|
+
const dy = p.y - other.cy;
|
|
3091
|
+
if (dx * dx + dy * dy > other.br * other.br) continue;
|
|
3092
|
+
if (other.contains(p.x, p.y)) {
|
|
3093
|
+
hit++;
|
|
3094
|
+
break;
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
covered += hit / pts.length;
|
|
3099
|
+
traced++;
|
|
3100
|
+
}
|
|
3101
|
+
if (traced === 0) return { ...base, overlap: null, reads: null };
|
|
3102
|
+
const overlap = Number((covered / traced).toFixed(4));
|
|
3103
|
+
const reads = overlap < MERGE_FLOOR ? "marks" : seamCause === null ? "mass" : "seamed";
|
|
3104
|
+
return { ...base, overlap, reads };
|
|
3105
|
+
});
|
|
3106
|
+
}
|
|
3107
|
+
function seamsWorthWarning(layer, c, entityCount) {
|
|
3108
|
+
if (c.reads !== "seamed" || entityCount < 6) return false;
|
|
3109
|
+
const [lo, hi] = layer.alpha ?? [1, 1];
|
|
3110
|
+
if ((lo + hi) / 2 < WASH_ALPHA_MAX) return false;
|
|
3111
|
+
return layer.blend !== "lighter" && layer.blend !== "screen";
|
|
3112
|
+
}
|
|
3113
|
+
|
|
2422
3114
|
// src/advise.ts
|
|
2423
3115
|
var LOW_CONTRAST_FLOOR = 0.05;
|
|
2424
|
-
|
|
3116
|
+
var SPARSE_DECLARED_MAX_COVERAGE = 0.02;
|
|
3117
|
+
function adviseSpec(spec, viewport = { width: 1920, height: 1080 }, opts = {}) {
|
|
2425
3118
|
const warnings = [];
|
|
2426
3119
|
const w = viewport.width;
|
|
2427
3120
|
const h = viewport.height;
|
|
2428
3121
|
const scale = spec.units === "px" ? 1 : Math.min(w, h);
|
|
2429
3122
|
const refVp = spec.referenceViewport ?? LIMITS.referenceViewport;
|
|
2430
|
-
|
|
2431
|
-
|
|
3123
|
+
let countScale = scale > 1 ? Math.min(w, h) / refVp : 1;
|
|
3124
|
+
if (countScale > 1) {
|
|
3125
|
+
const rawTotal = spec.layers.reduce((sum, l) => sum + Math.round(l.count * countScale), 0);
|
|
3126
|
+
if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
|
|
3127
|
+
}
|
|
3128
|
+
const rng = createRng2(opts.seed ?? spec.seed ?? 42);
|
|
2432
3129
|
const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
|
|
2433
3130
|
const bgLuma = backgroundLuma(spec);
|
|
2434
3131
|
const bgRgb = backgroundRgb(spec);
|
|
3132
|
+
const cohesion = cohesionOf(
|
|
3133
|
+
spec.layers.map((layer, i) => ({ layer, entities: allEntities[i] })),
|
|
3134
|
+
opts.t ?? COHESION_T,
|
|
3135
|
+
w,
|
|
3136
|
+
h
|
|
3137
|
+
);
|
|
2435
3138
|
let totalEntities = 0;
|
|
2436
3139
|
let textLayerCount = 0;
|
|
2437
3140
|
let motionLayerCount = 0;
|
|
@@ -2443,6 +3146,14 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
2443
3146
|
const isStaticText = isText && layer.motion.type === "static";
|
|
2444
3147
|
if (isStaticText) textLayerCount++;
|
|
2445
3148
|
if (layer.motion.type !== "static") motionLayerCount++;
|
|
3149
|
+
const coh = cohesion[li];
|
|
3150
|
+
if (seamsWorthWarning(layer, coh, entities.length)) {
|
|
3151
|
+
warnings.push({
|
|
3152
|
+
path: `layers[${li}]`,
|
|
3153
|
+
code: "overlap-seams",
|
|
3154
|
+
message: `entities bury ${Math.round((coh.overlap ?? 0) * 100)}% of each other's outlines, so this layer is drawn as one shape \u2014 but ${coh.seamCause}, so every overlap paints a visible edge and it will read as a pile of sprites instead. A layer merges into a single silhouette only when it is one flat colour at alpha 1, hard-edged, with no blend and no pulse.`
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
2446
3157
|
if (layer.trail && layer.motion.type === "static") {
|
|
2447
3158
|
warnings.push({
|
|
2448
3159
|
path: `layers[${li}].trail`,
|
|
@@ -2450,11 +3161,11 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
2450
3161
|
message: "trail has no effect on static entities \u2014 they have no past positions to draw"
|
|
2451
3162
|
});
|
|
2452
3163
|
}
|
|
2453
|
-
if (layer.sprite.kind === "streak" && layer.motion.type === "static") {
|
|
3164
|
+
if ((layer.sprite.kind === "streak" || layer.sprite.kind === "stroke" && layer.sprite.orient) && layer.motion.type === "static") {
|
|
2454
3165
|
warnings.push({
|
|
2455
3166
|
path: `layers[${li}].sprite`,
|
|
2456
3167
|
code: "streak-on-static",
|
|
2457
|
-
message: "streak sprites orient along the motion heading \u2014 static entities have none and
|
|
3168
|
+
message: `${layer.sprite.kind === "streak" ? "streak sprites orient" : "an oriented stroke turns"} along the motion heading \u2014 static entities have none, so the heading contributes nothing${layer.spin ? " (spin still rotates the mark)" : " and it renders at angle 0"}`
|
|
2458
3169
|
});
|
|
2459
3170
|
}
|
|
2460
3171
|
if (layer.motion.type === "wander" && layer.motion.coherence === 1 && entities.length >= 20) {
|
|
@@ -2501,6 +3212,8 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
2501
3212
|
let pixArea;
|
|
2502
3213
|
if (layer.sprite.kind === "circle") {
|
|
2503
3214
|
pixArea = Math.PI * r * r;
|
|
3215
|
+
} else if (layer.sprite.kind === "bar") {
|
|
3216
|
+
pixArea = e.size * barFraction(layer.sprite, e.barIndex ?? 0) * (e.size2 ?? e.size * 0.2);
|
|
2504
3217
|
} else if (layer.sprite.kind === "textBlock") {
|
|
2505
3218
|
const fsPx = layer.sprite.fontSize * scale;
|
|
2506
3219
|
const lh = (layer.sprite.lineHeight ?? 1.4) * fsPx;
|
|
@@ -2510,31 +3223,53 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
|
|
|
2510
3223
|
} else {
|
|
2511
3224
|
pixArea = e.size * e.size;
|
|
2512
3225
|
}
|
|
2513
|
-
|
|
3226
|
+
let duty = 1;
|
|
3227
|
+
if (layer.emit) {
|
|
3228
|
+
const [ga, gb] = layer.emit.grow ?? [1, 1];
|
|
3229
|
+
const g2 = (ga * ga + ga * gb + gb * gb) / 3;
|
|
3230
|
+
duty = Math.min(layer.emit.life, layer.emit.every) / layer.emit.every * 0.5 * g2;
|
|
3231
|
+
}
|
|
3232
|
+
totalCoverage += pixArea * e.alpha * duty / (w * h);
|
|
2514
3233
|
}
|
|
2515
3234
|
if (layer.links) {
|
|
2516
3235
|
const positions = entities.map((e) => positionAt(e, 0, w, h));
|
|
2517
3236
|
const maxDistPx = layer.links.maxDist * scale;
|
|
2518
3237
|
const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false, w, h);
|
|
2519
|
-
const
|
|
3238
|
+
const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
3239
|
+
const lwPx = (layer.links.width ?? defaultWidth) * scale;
|
|
2520
3240
|
const la = layer.links.alpha ?? 1;
|
|
2521
3241
|
for (const edge of edges) totalCoverage += edge.dist * lwPx * la / (w * h);
|
|
2522
3242
|
}
|
|
2523
3243
|
}
|
|
2524
|
-
|
|
3244
|
+
const density = spec.density ?? "normal";
|
|
3245
|
+
const wouldBeSparse = totalCoverage < 5e-4 && spec.layers.length > 0;
|
|
3246
|
+
if (wouldBeSparse && density !== "sparse") {
|
|
2525
3247
|
warnings.push({
|
|
2526
3248
|
path: "layers",
|
|
2527
3249
|
code: "sparse-scene",
|
|
2528
3250
|
message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
|
|
2529
3251
|
});
|
|
2530
3252
|
}
|
|
2531
|
-
if (totalEntities > 500) {
|
|
3253
|
+
if (totalEntities > 500 && density !== "dense") {
|
|
2532
3254
|
warnings.push({
|
|
2533
3255
|
path: "layers",
|
|
2534
3256
|
code: "dense-scene",
|
|
2535
3257
|
message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
|
|
2536
3258
|
});
|
|
2537
3259
|
}
|
|
3260
|
+
if (density === "sparse" && totalCoverage > SPARSE_DECLARED_MAX_COVERAGE) {
|
|
3261
|
+
warnings.push({
|
|
3262
|
+
path: "density",
|
|
3263
|
+
code: "density-mismatch",
|
|
3264
|
+
message: `declared sparse but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(2)}% \u2014 either the declaration or the scene is wrong`
|
|
3265
|
+
});
|
|
3266
|
+
} else if (density === "dense" && wouldBeSparse) {
|
|
3267
|
+
warnings.push({
|
|
3268
|
+
path: "density",
|
|
3269
|
+
code: "density-mismatch",
|
|
3270
|
+
message: `declared dense but alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 the scene will look empty`
|
|
3271
|
+
});
|
|
3272
|
+
}
|
|
2538
3273
|
if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
|
|
2539
3274
|
const textRatio = textLayerCount / spec.layers.length;
|
|
2540
3275
|
if (textRatio >= 0.8) {
|
|
@@ -2982,6 +3717,36 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
2982
3717
|
const p = posOf(scene, e, tPass);
|
|
2983
3718
|
const sz = sizeAt(e, tPass);
|
|
2984
3719
|
const s = layer.sprite;
|
|
3720
|
+
if (s.kind === "stroke") {
|
|
3721
|
+
const pts = strokeSamples(s, sz / 2);
|
|
3722
|
+
let angle = rotationAt(e, tPass);
|
|
3723
|
+
if (s.orient) angle += headingAt(e, tPass, w, h) ?? 0;
|
|
3724
|
+
const ca = Math.cos(angle);
|
|
3725
|
+
const sa = Math.sin(angle);
|
|
3726
|
+
const lw = strokeWidthPx(s, scale);
|
|
3727
|
+
const stampAt = (q, u) => {
|
|
3728
|
+
const x = p.x + q.x * ca - q.y * sa;
|
|
3729
|
+
const y = p.y + q.x * sa + q.y * ca;
|
|
3730
|
+
const c = Math.floor(x / cellW);
|
|
3731
|
+
const r = Math.floor(y / cellH);
|
|
3732
|
+
if (c < 0 || c >= cols || r < 0 || r >= rows) return;
|
|
3733
|
+
const taper = s.taper ? strokeTaper(u) : 1;
|
|
3734
|
+
compose(r * cols + c, lum, a * Math.min(1, lw * taper / cellH), layer.blend);
|
|
3735
|
+
};
|
|
3736
|
+
const step = Math.min(cellW, cellH) / 2;
|
|
3737
|
+
stampAt(pts[0], 0);
|
|
3738
|
+
for (let i = 1; i < pts.length; i++) {
|
|
3739
|
+
const q0 = pts[i - 1];
|
|
3740
|
+
const q1 = pts[i];
|
|
3741
|
+
const segLen = Math.hypot(q1.x - q0.x, q1.y - q0.y);
|
|
3742
|
+
const sub = Math.max(1, Math.ceil(segLen / step));
|
|
3743
|
+
for (let k = 1; k <= sub; k++) {
|
|
3744
|
+
const f = k / sub;
|
|
3745
|
+
stampAt({ x: q0.x + (q1.x - q0.x) * f, y: q0.y + (q1.y - q0.y) * f }, (i - 1 + f) / (pts.length - 1));
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
continue;
|
|
3749
|
+
}
|
|
2985
3750
|
if (s.kind === "streak") {
|
|
2986
3751
|
const heading = headingAt(e, tPass, w, h) ?? 0;
|
|
2987
3752
|
const steps = Math.max(2, Math.ceil(sz / Math.min(cellW, cellH)));
|
|
@@ -3013,9 +3778,22 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
3013
3778
|
centerY = box.cy;
|
|
3014
3779
|
halfX = box.halfX;
|
|
3015
3780
|
halfY = box.halfY;
|
|
3781
|
+
} else if (s.kind === "bar") {
|
|
3782
|
+
const len = sz * barFraction(s, e.barIndex ?? 0);
|
|
3783
|
+
if (len <= 0) continue;
|
|
3784
|
+
const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
|
|
3785
|
+
const box = barBox(s.direction ?? "right", len, th);
|
|
3786
|
+
const rot = rotationAt(e, tPass);
|
|
3787
|
+
const cr = Math.cos(rot);
|
|
3788
|
+
const sr = Math.sin(rot);
|
|
3789
|
+
centerX = p.x + box.cx * cr - box.cy * sr;
|
|
3790
|
+
centerY = p.y + box.cx * sr + box.cy * cr;
|
|
3791
|
+
halfX = Math.abs(box.halfX * cr) + Math.abs(box.halfY * sr);
|
|
3792
|
+
halfY = Math.abs(box.halfX * sr) + Math.abs(box.halfY * cr);
|
|
3016
3793
|
}
|
|
3017
|
-
const circular = s.kind === "circle" || s.kind === "ring";
|
|
3018
|
-
const soft = s.kind === "circle" && !!s.soft;
|
|
3794
|
+
const circular = s.kind === "circle" || s.kind === "ring" || s.kind === "polygon";
|
|
3795
|
+
const soft = (s.kind === "circle" || s.kind === "polygon") && !!s.soft;
|
|
3796
|
+
const shapeWeight = s.kind === "polygon" ? polygonFill(s, sz / 2) : s.kind === "rect" && s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
|
|
3019
3797
|
const additive = layer.blend === "lighter" || layer.blend === "screen";
|
|
3020
3798
|
const glow = soft && additive;
|
|
3021
3799
|
const reachX = glow ? halfX * GLOW_SPREAD : halfX;
|
|
@@ -3048,9 +3826,9 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
3048
3826
|
} else {
|
|
3049
3827
|
wgt = soft ? Math.max(0.1, 1 - d / Math.max(halfX, 1e-6)) : 1;
|
|
3050
3828
|
}
|
|
3051
|
-
compose(r * cols + c, lum, a * wgt, layer.blend);
|
|
3829
|
+
compose(r * cols + c, lum, a * wgt * shapeWeight, layer.blend);
|
|
3052
3830
|
} else {
|
|
3053
|
-
compose(r * cols + c, lum, a * inkWeight, layer.blend);
|
|
3831
|
+
compose(r * cols + c, lum, a * inkWeight * shapeWeight, layer.blend);
|
|
3054
3832
|
}
|
|
3055
3833
|
}
|
|
3056
3834
|
}
|
|
@@ -3062,7 +3840,8 @@ function luminanceGrid(spec, opts = {}) {
|
|
|
3062
3840
|
const edges = linkEdges(layer.links, positions, maxDistPx, layer.wrap !== false && motionWraps, w, h);
|
|
3063
3841
|
const la = Math.min(1, (layer.links.alpha ?? 0.6) * lifeA);
|
|
3064
3842
|
const lum = layer.links.color ? hexLuma(layer.links.color) : 0.7;
|
|
3065
|
-
const
|
|
3843
|
+
const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
3844
|
+
const wgt = Math.min(1, (layer.links.width ?? defaultWidth) * scale / cellH) * la * alphaScale;
|
|
3066
3845
|
for (const edge of edges) {
|
|
3067
3846
|
const pi = positions[edge.i];
|
|
3068
3847
|
const pj = positions[edge.j];
|
|
@@ -3238,7 +4017,15 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
3238
4017
|
else if (s.kind === "streak") entArea = sz * ((s.width ?? (scale === 1 ? 2 : 2e-3)) * scale) * LINE_SALIENCE;
|
|
3239
4018
|
else if (s.kind === "rect") {
|
|
3240
4019
|
const h2 = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz;
|
|
3241
|
-
|
|
4020
|
+
const f = s.feather ? (1 - s.feather / 2) * (1 - s.feather / 2) : 1;
|
|
4021
|
+
entArea = sz * h2 * f;
|
|
4022
|
+
} else if (s.kind === "polygon") entArea = polygonArea(polygonPoints(s, sz / 2));
|
|
4023
|
+
else if (s.kind === "bar") {
|
|
4024
|
+
const th = e.size2 !== void 0 ? e.size2 * (e.size > 0 ? sz / e.size : 1) : sz * 0.2;
|
|
4025
|
+
entArea = sz * barFraction(s, e.barIndex ?? 0) * th;
|
|
4026
|
+
} else if (s.kind === "stroke") {
|
|
4027
|
+
const meanTaper = s.taper ? 0.64 : 1;
|
|
4028
|
+
entArea = pathLength(strokeSamples(s, sz / 2)) * strokeWidthPx(s, scale) * meanTaper * LINE_SALIENCE;
|
|
3242
4029
|
} else if (s.kind === "text") {
|
|
3243
4030
|
const box = textBox(s, e, { x: 0, y: 0 });
|
|
3244
4031
|
entArea = box.halfX * 2 * box.halfY * 2 * 0.55;
|
|
@@ -3262,7 +4049,8 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
3262
4049
|
const motionWraps = ["drift", "rise", "wander"].includes(layer.motion.type);
|
|
3263
4050
|
const edges = linkEdges(layer.links, positions, layer.links.maxDist * scale, layer.wrap !== false && motionWraps, w, h);
|
|
3264
4051
|
const la = (layer.links.alpha ?? 0.6) * lifeA;
|
|
3265
|
-
const
|
|
4052
|
+
const defaultWidth = scale === 1 ? 1 : 1 / LIMITS.referenceViewport;
|
|
4053
|
+
const lw = (layer.links.width ?? defaultWidth) * scale;
|
|
3266
4054
|
for (const edge of edges) area += edge.dist * lw * la * LINE_SALIENCE;
|
|
3267
4055
|
}
|
|
3268
4056
|
const meanLuma = entities.length ? lumAcc / entities.length : 0;
|
|
@@ -3283,6 +4071,10 @@ function dominanceRanking(spec, opts = {}) {
|
|
|
3283
4071
|
factors: r.factors
|
|
3284
4072
|
}));
|
|
3285
4073
|
}
|
|
4074
|
+
function layerCohesion(spec, opts = {}) {
|
|
4075
|
+
const scene = buildScene(spec, opts);
|
|
4076
|
+
return cohesionOf(scene.layers, opts.t ?? 5e3, scene.w, scene.h);
|
|
4077
|
+
}
|
|
3286
4078
|
function motionStats(spec, opts = {}) {
|
|
3287
4079
|
const scene = buildScene(spec, opts);
|
|
3288
4080
|
const t = opts.t ?? 5e3;
|
|
@@ -3379,8 +4171,9 @@ function perceiveScene(spec, opts = {}) {
|
|
|
3379
4171
|
colProfile: grid.colProfile,
|
|
3380
4172
|
dominance: dominanceRanking(spec, opts),
|
|
3381
4173
|
motion: motionStats(spec, opts),
|
|
4174
|
+
form: layerCohesion(spec, opts),
|
|
3382
4175
|
text: textSprites(spec, opts),
|
|
3383
|
-
advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 })
|
|
4176
|
+
advisories: adviseSpec(spec, opts.viewport ?? { width: 1920, height: 1080 }, { t: opts.t, seed: opts.seed })
|
|
3384
4177
|
};
|
|
3385
4178
|
}
|
|
3386
4179
|
|
|
@@ -4096,6 +4889,77 @@ var DASHBOARD_SPEC = {
|
|
|
4096
4889
|
]
|
|
4097
4890
|
};
|
|
4098
4891
|
|
|
4892
|
+
// src/examples/facets.ts
|
|
4893
|
+
var FACETS_SPEC = {
|
|
4894
|
+
schemaVersion: 1,
|
|
4895
|
+
id: "facets",
|
|
4896
|
+
label: "Facets",
|
|
4897
|
+
seed: 1911,
|
|
4898
|
+
motionIntensity: "calm",
|
|
4899
|
+
background: {
|
|
4900
|
+
type: "gradient",
|
|
4901
|
+
stops: [
|
|
4902
|
+
{ at: 0, color: "#101626" },
|
|
4903
|
+
{ at: 1, color: "#1c1a2e" }
|
|
4904
|
+
]
|
|
4905
|
+
},
|
|
4906
|
+
layers: [
|
|
4907
|
+
{
|
|
4908
|
+
key: "planes",
|
|
4909
|
+
count: 3,
|
|
4910
|
+
sprite: {
|
|
4911
|
+
kind: "rect",
|
|
4912
|
+
width: [0.28, 0.42],
|
|
4913
|
+
aspect: [0.6, 1.4],
|
|
4914
|
+
color: "#b3402f",
|
|
4915
|
+
colors: ["#b3402f", "#c9862b", "#2d4f8a"],
|
|
4916
|
+
feather: 0.6
|
|
4917
|
+
},
|
|
4918
|
+
alpha: [0.35, 0.5],
|
|
4919
|
+
region: { x: [0.2, 0.8], y: [0.2, 0.8] },
|
|
4920
|
+
motion: { type: "wander", speed: [1e-3, 3e-3], meander: 0.02 },
|
|
4921
|
+
spin: [-2, 2]
|
|
4922
|
+
},
|
|
4923
|
+
{
|
|
4924
|
+
key: "facets",
|
|
4925
|
+
count: 14,
|
|
4926
|
+
sprite: {
|
|
4927
|
+
kind: "polygon",
|
|
4928
|
+
radius: [0.02, 0.06],
|
|
4929
|
+
points: [[-1, 0.9], [0.2, -1], [1, 0.4]],
|
|
4930
|
+
color: "#f2e8c9",
|
|
4931
|
+
colors: ["#f2e8c9", "#e0b53a", "#6fa8dc", "#d94f3d"]
|
|
4932
|
+
},
|
|
4933
|
+
alpha: [0.6, 0.9],
|
|
4934
|
+
motion: { type: "wander", speed: [3e-3, 8e-3], meander: 0.03 },
|
|
4935
|
+
spin: [-8, 8]
|
|
4936
|
+
},
|
|
4937
|
+
{
|
|
4938
|
+
key: "arcs",
|
|
4939
|
+
count: 12,
|
|
4940
|
+
sprite: {
|
|
4941
|
+
kind: "stroke",
|
|
4942
|
+
length: [0.08, 0.18],
|
|
4943
|
+
points: [[-1, 0.3], [-0.5, -0.6], [0.3, -0.7], [1, 0.1]],
|
|
4944
|
+
width: 4e-3,
|
|
4945
|
+
taper: true,
|
|
4946
|
+
color: "#f2e8c9"
|
|
4947
|
+
},
|
|
4948
|
+
alpha: [0.5, 0.8],
|
|
4949
|
+
motion: { type: "drift", speed: [4e-3, 0.01], angle: 20, bidirectional: true },
|
|
4950
|
+
spin: [-4, 4]
|
|
4951
|
+
},
|
|
4952
|
+
{
|
|
4953
|
+
key: "hexes",
|
|
4954
|
+
count: 8,
|
|
4955
|
+
sprite: { kind: "polygon", radius: [8e-3, 0.016], sides: 6, color: "#e0b53a", soft: true },
|
|
4956
|
+
alpha: [0.5, 0.8],
|
|
4957
|
+
blend: "lighter",
|
|
4958
|
+
motion: { type: "wander", speed: [2e-3, 5e-3] }
|
|
4959
|
+
}
|
|
4960
|
+
]
|
|
4961
|
+
};
|
|
4962
|
+
|
|
4099
4963
|
// src/examples/haiku.ts
|
|
4100
4964
|
var HAIKU_SPEC = {
|
|
4101
4965
|
schemaVersion: 1,
|
|
@@ -4488,6 +5352,40 @@ var ORRERY_SPEC = {
|
|
|
4488
5352
|
]
|
|
4489
5353
|
};
|
|
4490
5354
|
|
|
5355
|
+
// src/examples/pings.ts
|
|
5356
|
+
var PINGS_SPEC = {
|
|
5357
|
+
schemaVersion: 1,
|
|
5358
|
+
id: "pings",
|
|
5359
|
+
label: "Pings",
|
|
5360
|
+
seed: 7,
|
|
5361
|
+
motionIntensity: "calm",
|
|
5362
|
+
ghosting: 0.9,
|
|
5363
|
+
background: { type: "solid", color: "#03060a" },
|
|
5364
|
+
layers: [
|
|
5365
|
+
{
|
|
5366
|
+
key: "ping",
|
|
5367
|
+
count: 3,
|
|
5368
|
+
sprite: { kind: "ring", radius: [0.05, 0.05], color: "#4fb3a8", width: 15e-4 },
|
|
5369
|
+
alpha: [0.35, 0.35],
|
|
5370
|
+
blend: "lighter",
|
|
5371
|
+
region: { x: [0.2, 0.8], y: [0.25, 0.75] },
|
|
5372
|
+
motion: { type: "static" },
|
|
5373
|
+
// One ring at a time: three entities evenly staggered across 12 s.
|
|
5374
|
+
emit: { every: 12e3, life: 4e3, jitter: 0, grow: [0.15, 2.4] }
|
|
5375
|
+
},
|
|
5376
|
+
{
|
|
5377
|
+
key: "motes",
|
|
5378
|
+
count: 14,
|
|
5379
|
+
sprite: { kind: "circle", radius: [12e-4, 2e-3], color: "#4fb3a8", soft: true },
|
|
5380
|
+
alpha: [0.1, 0.22],
|
|
5381
|
+
blend: "lighter",
|
|
5382
|
+
// Each mote is flung upward and comes to rest within ~2.5 s of its event.
|
|
5383
|
+
motion: { type: "rise", speed: [0.01, 0.02], sway: 4e-3, ease: { type: "settle", tau: 2500 } },
|
|
5384
|
+
emit: { every: 16e3, life: 7e3, grow: [0.6, 1.4] }
|
|
5385
|
+
}
|
|
5386
|
+
]
|
|
5387
|
+
};
|
|
5388
|
+
|
|
4491
5389
|
// src/examples/procession.ts
|
|
4492
5390
|
var PROCESSION_SPEC = {
|
|
4493
5391
|
schemaVersion: 1,
|
|
@@ -4584,6 +5482,74 @@ var RAIN_SPEC = {
|
|
|
4584
5482
|
]
|
|
4585
5483
|
};
|
|
4586
5484
|
|
|
5485
|
+
// src/examples/relay-board.ts
|
|
5486
|
+
var RELAY_BOARD_SPEC = {
|
|
5487
|
+
schemaVersion: 1,
|
|
5488
|
+
id: "relay-board",
|
|
5489
|
+
label: "Relay Board",
|
|
5490
|
+
seed: 9,
|
|
5491
|
+
motionIntensity: "calm",
|
|
5492
|
+
background: {
|
|
5493
|
+
type: "gradient",
|
|
5494
|
+
stops: [
|
|
5495
|
+
{ at: 0, color: "#05070d" },
|
|
5496
|
+
{ at: 1, color: "#0a0d18" }
|
|
5497
|
+
]
|
|
5498
|
+
},
|
|
5499
|
+
layers: [
|
|
5500
|
+
{
|
|
5501
|
+
key: "dust",
|
|
5502
|
+
count: 40,
|
|
5503
|
+
sprite: { kind: "circle", radius: [8e-4, 16e-4], color: "#22344a", soft: true },
|
|
5504
|
+
alpha: [0.2, 0.45],
|
|
5505
|
+
blend: "lighter",
|
|
5506
|
+
motion: { type: "wander", speed: [1e-3, 3e-3] }
|
|
5507
|
+
},
|
|
5508
|
+
{
|
|
5509
|
+
key: "title",
|
|
5510
|
+
count: 1,
|
|
5511
|
+
sprite: { kind: "textBlock", text: "RELAY LINKS \u2014 UPLINK MARGIN", maxWidth: 0.6, fontSize: 0.028, color: "#9fb4c9" },
|
|
5512
|
+
motion: { type: "static" },
|
|
5513
|
+
position: { x: 0.12, y: 0.16 }
|
|
5514
|
+
},
|
|
5515
|
+
{
|
|
5516
|
+
key: "labels",
|
|
5517
|
+
count: 6,
|
|
5518
|
+
sprite: { kind: "text", strings: ["VIREO-9", "TIDE", "ORRERY", "COMET", "LANTERN", "SAKURA"], font: "bold monospace", align: "left", color: "#e6e8ef" },
|
|
5519
|
+
size: [0.024, 0.024],
|
|
5520
|
+
motion: { type: "static" },
|
|
5521
|
+
position: { x: 0.12, y: 0.3 },
|
|
5522
|
+
layout: { type: "list", gap: 0.075 }
|
|
5523
|
+
},
|
|
5524
|
+
{
|
|
5525
|
+
key: "bars",
|
|
5526
|
+
count: 6,
|
|
5527
|
+
sprite: {
|
|
5528
|
+
kind: "bar",
|
|
5529
|
+
values: [82, 64, 91, 37, 58, 73],
|
|
5530
|
+
max: 100,
|
|
5531
|
+
length: 0.5,
|
|
5532
|
+
thickness: 0.02,
|
|
5533
|
+
color: "#17e8c8",
|
|
5534
|
+
colors: ["#17e8c8", "#35d0ff", "#ffd93b", "#ff6ad5", "#ad8cff", "#17e8c8"]
|
|
5535
|
+
},
|
|
5536
|
+
alpha: [0.85, 0.85],
|
|
5537
|
+
motion: { type: "static" },
|
|
5538
|
+
position: { x: 0.3, y: 0.3 },
|
|
5539
|
+
layout: { type: "list", gap: 0.075 }
|
|
5540
|
+
},
|
|
5541
|
+
{
|
|
5542
|
+
key: "readouts",
|
|
5543
|
+
count: 6,
|
|
5544
|
+
sprite: { kind: "text", strings: ["82%", "64%", "91%", "37%", "58%", "73%"], font: "monospace", align: "left", color: "#9fb4c9" },
|
|
5545
|
+
size: [0.02, 0.02],
|
|
5546
|
+
motion: { type: "static" },
|
|
5547
|
+
position: { x: 0.86, y: 0.3 },
|
|
5548
|
+
layout: { type: "list", gap: 0.075 }
|
|
5549
|
+
}
|
|
5550
|
+
]
|
|
5551
|
+
};
|
|
5552
|
+
|
|
4587
5553
|
// src/examples/sakura.ts
|
|
4588
5554
|
var SAKURA_SPEC = {
|
|
4589
5555
|
schemaVersion: 1,
|
|
@@ -4820,7 +5786,10 @@ var SCHEMA_EXAMPLES = [
|
|
|
4820
5786
|
{ id: "matrix-rain", label: "Matrix Rain", spec: MATRIX_RAIN_SPEC },
|
|
4821
5787
|
{ id: "procession", label: "Night Procession", spec: PROCESSION_SPEC },
|
|
4822
5788
|
{ id: "nostalghia-candle", label: "Nostalghia's Candle", spec: NOSTALGHIA_CANDLE_SPEC },
|
|
4823
|
-
{ id: "haiku", label: "Haiku", spec: HAIKU_SPEC }
|
|
5789
|
+
{ id: "haiku", label: "Haiku", spec: HAIKU_SPEC },
|
|
5790
|
+
{ id: "pings", label: "Pings", spec: PINGS_SPEC },
|
|
5791
|
+
{ id: "facets", label: "Facets", spec: FACETS_SPEC },
|
|
5792
|
+
{ id: "relay-board", label: "Relay Board", spec: RELAY_BOARD_SPEC }
|
|
4824
5793
|
];
|
|
4825
5794
|
var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
|
|
4826
5795
|
var EXAMPLE_BY_ID = Object.fromEntries(
|
|
@@ -4829,20 +5798,24 @@ var EXAMPLE_BY_ID = Object.fromEntries(
|
|
|
4829
5798
|
export {
|
|
4830
5799
|
AQUARIUM_SPEC,
|
|
4831
5800
|
AURORA_SPEC,
|
|
5801
|
+
COHESION_T,
|
|
4832
5802
|
COMETS_SPEC,
|
|
4833
5803
|
CONSTELLATION_SPEC,
|
|
4834
5804
|
DASHBOARD_SPEC,
|
|
4835
5805
|
EXAMPLE_BY_ID,
|
|
4836
5806
|
EXAMPLE_SPECS,
|
|
5807
|
+
FACETS_SPEC,
|
|
4837
5808
|
HAIKU_SPEC,
|
|
4838
5809
|
LANTERNS_SPEC,
|
|
4839
5810
|
LIMITS,
|
|
4840
5811
|
MATRIX_RAIN_SPEC,
|
|
4841
5812
|
NOSTALGHIA_CANDLE_SPEC,
|
|
4842
5813
|
ORRERY_SPEC,
|
|
5814
|
+
PINGS_SPEC,
|
|
4843
5815
|
POLYGONS_SPEC,
|
|
4844
5816
|
PROCESSION_SPEC,
|
|
4845
5817
|
RAIN_SPEC,
|
|
5818
|
+
RELAY_BOARD_SPEC,
|
|
4846
5819
|
SAKURA_SPEC,
|
|
4847
5820
|
SCHEMA_EXAMPLES,
|
|
4848
5821
|
SCHEMA_VERSION,
|
|
@@ -4857,14 +5830,18 @@ export {
|
|
|
4857
5830
|
assertValidSpec,
|
|
4858
5831
|
breakTextBlock,
|
|
4859
5832
|
buildEntities,
|
|
5833
|
+
cohesionOf,
|
|
4860
5834
|
compileSaver,
|
|
4861
5835
|
compileSequence,
|
|
4862
5836
|
describeScene,
|
|
4863
5837
|
diffScenes,
|
|
4864
5838
|
dominanceRanking,
|
|
4865
5839
|
easeSmooth,
|
|
5840
|
+
emitEnvelope,
|
|
5841
|
+
emitWindow,
|
|
4866
5842
|
graphemeClusters,
|
|
4867
5843
|
headingAt,
|
|
5844
|
+
layerCohesion,
|
|
4868
5845
|
lerpSpec,
|
|
4869
5846
|
lifeAlphaAt,
|
|
4870
5847
|
linkEdges,
|
|
@@ -4880,6 +5857,8 @@ export {
|
|
|
4880
5857
|
resolveSpecPath,
|
|
4881
5858
|
revealState,
|
|
4882
5859
|
rotationAt,
|
|
5860
|
+
seamCauseOf,
|
|
5861
|
+
seamsWorthWarning,
|
|
4883
5862
|
segmentStart,
|
|
4884
5863
|
sizeAt,
|
|
4885
5864
|
spriteIndexAt,
|