@idle-screens/schema 2.1.0 → 2.2.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/index.js CHANGED
@@ -20,8 +20,16 @@ var LIMITS = {
20
20
  maxLinkLayerCount: 200,
21
21
  minCyclePeriod: 500,
22
22
  // ms — same flash-safety floor as pulse
23
- referenceViewport: 1080
23
+ referenceViewport: 1080,
24
24
  // for validating viewport-unit dimensional caps
25
+ maxTrailLength: 5e3,
26
+ // ms — cap trail duration
27
+ maxTrailSamples: 24,
28
+ // dots per trail
29
+ minDriftPeriod: 1e4,
30
+ // ms — background drift floor (10 s)
31
+ maxDriftAmount: 0.3
32
+ // fraction of gradient stop shift
25
33
  };
26
34
 
27
35
  // src/validate.ts
@@ -30,10 +38,45 @@ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
30
38
  var isStr = (v) => typeof v === "string";
31
39
  var isNum = (v) => typeof v === "number" && Number.isFinite(v);
32
40
  var isRange = (v) => Array.isArray(v) && v.length === 2 && isNum(v[0]) && isNum(v[1]) && v[0] <= v[1];
41
+ var KNOWN_TOP = /* @__PURE__ */ new Set(["schemaVersion", "id", "label", "seed", "motionIntensity", "units", "background", "layers"]);
42
+ var KNOWN_LAYER = /* @__PURE__ */ new Set([
43
+ "count",
44
+ "sprite",
45
+ "motion",
46
+ "size",
47
+ "wrap",
48
+ "flip",
49
+ "alpha",
50
+ "blend",
51
+ "region",
52
+ "pulse",
53
+ "spin",
54
+ "grow",
55
+ "key",
56
+ "position",
57
+ "trail",
58
+ "links"
59
+ ]);
60
+ var KNOWN_CIRCLE = /* @__PURE__ */ new Set(["kind", "radius", "color", "soft", "colors"]);
61
+ var KNOWN_EMOJI = /* @__PURE__ */ new Set(["kind", "glyphs", "cycle"]);
62
+ var KNOWN_TEXT = /* @__PURE__ */ new Set(["kind", "strings", "color", "font", "align", "baseline", "maxWidth", "cycle"]);
63
+ var KNOWN_DRIFT = /* @__PURE__ */ new Set(["type", "speed", "angle", "bidirectional", "bob"]);
64
+ var KNOWN_RISE = /* @__PURE__ */ new Set(["type", "speed", "sway"]);
65
+ var KNOWN_BOUNCE = /* @__PURE__ */ new Set(["type", "speed"]);
66
+ var KNOWN_STATIC = /* @__PURE__ */ new Set(["type"]);
67
+ var KNOWN_ORBIT = /* @__PURE__ */ new Set(["type", "speed", "radius", "center"]);
68
+ var KNOWN_BG_SOLID = /* @__PURE__ */ new Set(["type", "color"]);
69
+ var KNOWN_BG_GRADIENT = /* @__PURE__ */ new Set(["type", "stops", "band", "drift"]);
70
+ var LAYER_PROPS_ON_SPRITE = /* @__PURE__ */ new Set(["blend", "trail", "alpha", "pulse", "spin", "grow", "region", "links", "flip", "wrap", "key"]);
71
+ function unknownKeys(obj, known) {
72
+ return Object.keys(obj).filter((k) => !known.has(k));
73
+ }
33
74
  function validateSpec(spec) {
34
75
  const errors = [];
76
+ const warnings = [];
35
77
  const err = (path, message) => void errors.push({ path, message });
36
- if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }] };
78
+ const warn = (path, code, message) => void warnings.push({ path, code, message });
79
+ if (!isObj(spec)) return { valid: false, errors: [{ path: "", message: "spec must be an object" }], warnings: [] };
37
80
  if (spec.schemaVersion !== SCHEMA_VERSION) err("schemaVersion", `must be ${SCHEMA_VERSION}`);
38
81
  if (!isStr(spec.id) || spec.id.trim() === "") err("id", "must be a non-empty string");
39
82
  if (!isStr(spec.label) || spec.label.trim() === "") err("label", "must be a non-empty string");
@@ -44,7 +87,10 @@ function validateSpec(spec) {
44
87
  if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
45
88
  err("units", "must be 'px' | 'viewport'");
46
89
  }
47
- if (spec.background !== void 0) validateBackground(spec.background, err);
90
+ for (const k of unknownKeys(spec, KNOWN_TOP)) {
91
+ warn(k, "unknown-property", `unknown top-level property '${k}' \u2014 will be ignored`);
92
+ }
93
+ if (spec.background !== void 0) validateBackground(spec.background, err, warn);
48
94
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
49
95
  err("layers", "must be a non-empty array");
50
96
  } else {
@@ -52,19 +98,22 @@ function validateSpec(spec) {
52
98
  let total = 0;
53
99
  spec.layers.forEach((layer, i) => {
54
100
  total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
55
- validateLayer(layer, `layers[${i}]`, err, spec);
101
+ validateLayer(layer, `layers[${i}]`, err, warn, spec);
56
102
  });
57
103
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
58
104
  }
59
- return { valid: errors.length === 0, errors };
105
+ return { valid: errors.length === 0, errors, warnings };
60
106
  }
61
107
  function color(v, path, err) {
62
108
  if (!isStr(v) || !HEX.test(v)) err(path, "must be a hex colour like #0a3a52");
63
109
  }
64
- function validateBackground(bg, err) {
110
+ function validateBackground(bg, err, warn) {
65
111
  if (!isObj(bg)) return err("background", "must be an object");
66
112
  if (bg.type === "solid") {
67
113
  color(bg.color, "background.color", err);
114
+ for (const k of unknownKeys(bg, KNOWN_BG_SOLID)) {
115
+ warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
116
+ }
68
117
  } else if (bg.type === "gradient") {
69
118
  if (!Array.isArray(bg.stops) || bg.stops.length < 2) {
70
119
  err("background.stops", "gradient needs at least 2 stops");
@@ -74,6 +123,17 @@ function validateBackground(bg, err) {
74
123
  color(isObj(s) ? s.color : void 0, `background.stops[${i}].color`, err);
75
124
  });
76
125
  }
126
+ if (bg.drift !== void 0) {
127
+ if (!isObj(bg.drift)) err("background.drift", "must be an object");
128
+ else {
129
+ if (!isNum(bg.drift.period) || bg.drift.period < LIMITS.minDriftPeriod) {
130
+ err("background.drift.period", `must be >= ${LIMITS.minDriftPeriod} ms`);
131
+ }
132
+ if (bg.drift.amount !== void 0 && (!isNum(bg.drift.amount) || bg.drift.amount <= 0 || bg.drift.amount > LIMITS.maxDriftAmount)) {
133
+ err("background.drift.amount", `must be > 0 and <= ${LIMITS.maxDriftAmount}`);
134
+ }
135
+ }
136
+ }
77
137
  if (bg.band !== void 0) {
78
138
  if (!isObj(bg.band)) err("background.band", "must be an object");
79
139
  else {
@@ -81,12 +141,24 @@ function validateBackground(bg, err) {
81
141
  if (!isNum(bg.band.height) || bg.band.height <= 0) err("background.band.height", "must be > 0");
82
142
  }
83
143
  }
144
+ for (const k of unknownKeys(bg, KNOWN_BG_GRADIENT)) {
145
+ warn(`background.${k}`, "unknown-property", `unknown background property '${k}' \u2014 will be ignored`);
146
+ }
84
147
  } else {
85
148
  err("background.type", "must be solid | gradient");
86
149
  }
87
150
  }
88
- function validateLayer(layer, path, err, spec) {
151
+ function validateLayer(layer, path, err, warn, spec) {
89
152
  if (!isObj(layer)) return err(path, "must be an object");
153
+ for (const k of unknownKeys(layer, KNOWN_LAYER)) {
154
+ if (k === "id") {
155
+ warn(`${path}.id`, "misplaced-property", `'id' is not a layer property \u2014 did you mean 'key'? Use 'key' to name a layer`);
156
+ } else if (k === "depth") {
157
+ warn(`${path}.depth`, "unknown-property", `'depth' is not yet supported \u2014 fake parallax with size/speed/alpha across layers instead`);
158
+ } else {
159
+ warn(`${path}.${k}`, "unknown-property", `unknown layer property '${k}' \u2014 will be ignored`);
160
+ }
161
+ }
90
162
  if (!isNum(layer.count) || !Number.isInteger(layer.count) || layer.count < 1) {
91
163
  err(`${path}.count`, "must be a positive integer");
92
164
  } else if (layer.count > LIMITS.maxPerLayer) {
@@ -171,17 +243,31 @@ function validateLayer(layer, path, err, spec) {
171
243
  }
172
244
  }
173
245
  }
174
- validateSprite(layer.sprite, `${path}.sprite`, err);
175
- validateMotion(layer.motion, `${path}.motion`, err, spec);
246
+ if (layer.trail !== void 0) {
247
+ if (!isObj(layer.trail)) err(`${path}.trail`, "must be an object");
248
+ else {
249
+ if (!isNum(layer.trail.length) || layer.trail.length <= 0 || layer.trail.length > LIMITS.maxTrailLength) {
250
+ err(`${path}.trail.length`, `must be > 0 and <= ${LIMITS.maxTrailLength} ms`);
251
+ }
252
+ if (layer.trail.fade !== void 0 && (!isNum(layer.trail.fade) || layer.trail.fade < 0 || layer.trail.fade > 1)) {
253
+ err(`${path}.trail.fade`, "must be 0..1");
254
+ }
255
+ }
256
+ }
257
+ validateSprite(layer.sprite, `${path}.sprite`, err, warn);
258
+ validateMotion(layer.motion, `${path}.motion`, err, warn, spec);
176
259
  }
177
- function validateSprite(sprite, path, err) {
260
+ function validateSprite(sprite, path, err, warn) {
178
261
  if (!isObj(sprite)) return err(path, "must be an object");
262
+ let knownSet;
179
263
  if (sprite.kind === "emoji") {
264
+ knownSet = KNOWN_EMOJI;
180
265
  if (!Array.isArray(sprite.glyphs) || sprite.glyphs.length === 0 || !sprite.glyphs.every(isStr)) {
181
266
  err(`${path}.glyphs`, "must be a non-empty array of strings");
182
267
  }
183
268
  validateCycle(sprite, path, err);
184
269
  } else if (sprite.kind === "text") {
270
+ knownSet = KNOWN_TEXT;
185
271
  if (!Array.isArray(sprite.strings) || sprite.strings.length === 0 || !sprite.strings.every(isStr)) {
186
272
  err(`${path}.strings`, "must be a non-empty array of strings");
187
273
  }
@@ -197,6 +283,7 @@ function validateSprite(sprite, path, err) {
197
283
  }
198
284
  validateCycle(sprite, path, err);
199
285
  } else if (sprite.kind === "circle") {
286
+ knownSet = KNOWN_CIRCLE;
200
287
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
201
288
  color(sprite.color, `${path}.color`, err);
202
289
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
@@ -209,6 +296,14 @@ function validateSprite(sprite, path, err) {
209
296
  }
210
297
  } else {
211
298
  err(`${path}.kind`, "must be emoji | text | circle");
299
+ return;
300
+ }
301
+ for (const k of unknownKeys(sprite, knownSet)) {
302
+ if (LAYER_PROPS_ON_SPRITE.has(k)) {
303
+ warn(`${path}.${k}`, "misplaced-property", `'${k}' belongs on the layer, not inside sprite \u2014 move it up one level`);
304
+ } else {
305
+ warn(`${path}.${k}`, "unknown-property", `unknown sprite property '${k}' \u2014 will be ignored`);
306
+ }
212
307
  }
213
308
  }
214
309
  function validateCycle(sprite, path, err) {
@@ -218,7 +313,7 @@ function validateCycle(sprite, path, err) {
218
313
  err(`${path}.cycle.period`, `must be >= ${LIMITS.minCyclePeriod} ms (flash-safety floor)`);
219
314
  }
220
315
  }
221
- function validateMotion(motion, path, err, spec) {
316
+ function validateMotion(motion, path, err, warn, spec) {
222
317
  if (!isObj(motion)) return err(path, "must be an object");
223
318
  const isViewport = isObj(spec) && spec.units === "viewport";
224
319
  const speedCap = isViewport ? LIMITS.maxSpeed / LIMITS.referenceViewport : LIMITS.maxSpeed;
@@ -226,20 +321,40 @@ function validateMotion(motion, path, err, spec) {
226
321
  if (!isRange(v)) err(p, "must be a [min,max] range");
227
322
  else if (v[1] > speedCap) err(p, `speed exceeds cap ${isViewport ? speedCap.toFixed(2) + " viewport-units/sec" : LIMITS.maxSpeed + " px/sec"}`);
228
323
  };
324
+ let knownSet;
229
325
  if (motion.type === "drift") {
326
+ knownSet = KNOWN_DRIFT;
230
327
  speedOk(motion.speed, `${path}.speed`);
231
328
  if (motion.angle !== void 0 && !isNum(motion.angle)) err(`${path}.angle`, "must be a number (degrees)");
232
329
  if (motion.bidirectional !== void 0 && typeof motion.bidirectional !== "boolean") err(`${path}.bidirectional`, "must be a boolean");
233
330
  if (motion.bob !== void 0 && !isNum(motion.bob)) err(`${path}.bob`, "must be a number");
331
+ if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
332
+ 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`);
333
+ }
234
334
  } else if (motion.type === "rise") {
335
+ knownSet = KNOWN_RISE;
235
336
  speedOk(motion.speed, `${path}.speed`);
236
337
  if (motion.sway !== void 0 && !isNum(motion.sway)) err(`${path}.sway`, "must be a number");
338
+ if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
339
+ 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`);
340
+ }
237
341
  } else if (motion.type === "bounce") {
342
+ knownSet = KNOWN_BOUNCE;
238
343
  speedOk(motion.speed, `${path}.speed`);
344
+ if (isRange(motion.speed) && motion.speed[1] < 1 && !isViewport) {
345
+ warn(`${path}.speed`, "near-zero-speed", `max speed is ${motion.speed[1]} px/sec \u2014 entities will appear frozen. Typical range: 20\u2013150 px/sec`);
346
+ }
239
347
  } else if (motion.type === "static") {
348
+ knownSet = KNOWN_STATIC;
240
349
  } else if (motion.type === "orbit") {
350
+ knownSet = KNOWN_ORBIT;
241
351
  if (!isRange(motion.speed)) err(`${path}.speed`, "must be a [min,max] range");
242
- else if (Math.abs(motion.speed[0]) > LIMITS.maxOrbitSpeed || Math.abs(motion.speed[1]) > LIMITS.maxOrbitSpeed) err(`${path}.speed`, `orbit speed exceeds cap ${LIMITS.maxOrbitSpeed} deg/sec`);
352
+ else {
353
+ if (Math.abs(motion.speed[0]) > LIMITS.maxOrbitSpeed || Math.abs(motion.speed[1]) > LIMITS.maxOrbitSpeed) err(`${path}.speed`, `orbit speed exceeds cap ${LIMITS.maxOrbitSpeed} deg/sec`);
354
+ if (Math.max(Math.abs(motion.speed[0]), Math.abs(motion.speed[1])) < 1) {
355
+ warn(`${path}.speed`, "near-zero-speed", `orbit speed is near zero \u2014 entities will appear frozen. Typical range: 5\u201360 deg/sec`);
356
+ }
357
+ }
243
358
  if (!isRange(motion.radius) || motion.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
244
359
  if (motion.center !== void 0) {
245
360
  if (!isObj(motion.center) || !isNum(motion.center.x) || !isNum(motion.center.y)) {
@@ -251,6 +366,10 @@ function validateMotion(motion, path, err, spec) {
251
366
  }
252
367
  } else {
253
368
  err(`${path}.type`, "must be drift | rise | bounce | static | orbit");
369
+ return;
370
+ }
371
+ for (const k of unknownKeys(motion, knownSet)) {
372
+ warn(`${path}.${k}`, "unknown-property", `unknown motion property '${k}' \u2014 will be ignored`);
254
373
  }
255
374
  }
256
375
  function assertValidSpec(spec) {
@@ -268,13 +387,17 @@ function spriteVariants(sprite) {
268
387
  if (sprite.kind === "text") return sprite.strings.length;
269
388
  return 1;
270
389
  }
271
- function buildEntities(layer, rng, w, h, scale = 1) {
390
+ function buildEntities(layer, rng, w, h, scale = 1, countScale = 1) {
391
+ const effectiveCount = countScale === 1 ? layer.count : Math.max(1, Math.min(
392
+ Math.round(layer.count * countScale),
393
+ layer.links ? LIMITS.maxLinkLayerCount : LIMITS.maxPerLayer
394
+ ));
272
395
  const [smin, smax] = layer.size ?? [20, 40];
273
396
  const variants = spriteVariants(layer.sprite);
274
397
  const colorsLen = layer.sprite.kind === "circle" && layer.sprite.colors ? layer.sprite.colors.length : 0;
275
398
  const cycle = layer.sprite.kind === "emoji" || layer.sprite.kind === "text" ? layer.sprite.cycle : void 0;
276
399
  const out = [];
277
- for (let i = 0; i < layer.count; i++) {
400
+ for (let i = 0; i < effectiveCount; i++) {
278
401
  const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 * scale : rng.range(smin, smax) * scale;
279
402
  let vx = 0;
280
403
  let vy = 0;
@@ -513,6 +636,32 @@ function lerpSpec(from, to, k) {
513
636
  };
514
637
  return walk(from, to);
515
638
  }
639
+ function steerablePaths(spec) {
640
+ if (!spec || typeof spec !== "object") return [];
641
+ const SKIP = /* @__PURE__ */ new Set(["kind", "type", "key", "schemaVersion", "id", "label", "seed", "units"]);
642
+ const INDEXED = /* @__PURE__ */ new Set(["layers", "stops"]);
643
+ const out = [];
644
+ const walk = (node, prefix, key) => {
645
+ if (Array.isArray(node) && INDEXED.has(key)) {
646
+ node.forEach((child, i) => walk(child, `${prefix}.${i}`, ""));
647
+ return;
648
+ }
649
+ if (Array.isArray(node)) {
650
+ if (prefix) out.push(prefix);
651
+ return;
652
+ }
653
+ if (node && typeof node === "object") {
654
+ for (const [k, v] of Object.entries(node)) {
655
+ if (SKIP.has(k)) continue;
656
+ walk(v, prefix ? `${prefix}.${k}` : k, k);
657
+ }
658
+ return;
659
+ }
660
+ if (prefix) out.push(prefix);
661
+ };
662
+ walk(spec, "", "");
663
+ return out;
664
+ }
516
665
  function easeSmooth(k) {
517
666
  const c = Math.max(0, Math.min(1, k));
518
667
  return c * c * (3 - 2 * c);
@@ -617,11 +766,16 @@ var SpecInstance = class {
617
766
  this.canvas.height = Math.max(1, Math.round(this.h * dpr));
618
767
  this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
619
768
  }
620
- /** (Re)seed and place all entities — deterministic for the seed + size. */
769
+ /** (Re)seed and place all entities — deterministic for the seed + viewport. */
621
770
  rebuild() {
622
771
  const rng = createRng(this.seed);
623
772
  const scale = this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1;
624
- this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale) }));
773
+ let countScale = scale > 1 ? Math.min(this.w, this.h) / LIMITS.referenceViewport : 1;
774
+ if (countScale > 1) {
775
+ const rawTotal = this.effSpec.layers.reduce((s, l) => s + Math.round(l.count * countScale), 0);
776
+ if (rawTotal > LIMITS.maxTotal) countScale *= LIMITS.maxTotal / rawTotal;
777
+ }
778
+ this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h, scale, countScale) }));
625
779
  this.lastStructural = structuralSignature(this.effSpec);
626
780
  }
627
781
  start() {
@@ -642,7 +796,7 @@ var SpecInstance = class {
642
796
  this.lastT = now - this.startT + this.baseT;
643
797
  this.renderFrame(this.lastT, this.seed);
644
798
  }
645
- drawBackground() {
799
+ drawBackground(t) {
646
800
  const { ctx, w, h } = this;
647
801
  const bg = this.effSpec.background;
648
802
  if (!bg || bg.type === "solid") {
@@ -651,7 +805,17 @@ var SpecInstance = class {
651
805
  return;
652
806
  }
653
807
  const g = ctx.createLinearGradient(0, 0, 0, h);
654
- for (const s of bg.stops) g.addColorStop(Math.max(0, Math.min(1, s.at)), s.color);
808
+ const drift = bg.drift;
809
+ for (let i = 0; i < bg.stops.length; i++) {
810
+ const s = bg.stops[i];
811
+ let at = s.at;
812
+ if (drift) {
813
+ const amount = drift.amount ?? 0.15;
814
+ const phase = i / bg.stops.length * Math.PI * 2;
815
+ at = Math.max(0, Math.min(1, at + amount * Math.sin(t * 2 * Math.PI / drift.period + phase)));
816
+ }
817
+ g.addColorStop(at, s.color);
818
+ }
655
819
  ctx.fillStyle = g;
656
820
  ctx.fillRect(0, 0, w, h);
657
821
  if (bg.band) {
@@ -660,6 +824,52 @@ var SpecInstance = class {
660
824
  ctx.fillRect(0, h - bh, w, bh);
661
825
  }
662
826
  }
827
+ drawTrail(built, e, t) {
828
+ const trail = built.layer.trail;
829
+ if (!trail) return;
830
+ const { ctx, w, h } = this;
831
+ const fade = trail.fade ?? 1;
832
+ const n = Math.min(Math.ceil(trail.length / 50), LIMITS.maxTrailSamples);
833
+ const headAlpha = alphaAt(e, t);
834
+ const headSize = sizeAt(e, t);
835
+ const sprite = built.layer.sprite;
836
+ const resolvedColor = sprite.kind === "circle" ? sprite.colors?.[e.colorIndex] ?? sprite.color : sprite.kind === "text" ? sprite.color ?? "#e6e8ef" : "#e6e8ef";
837
+ const isSoft = sprite.kind === "circle" && sprite.soft;
838
+ const wrap2 = built.layer.wrap !== false;
839
+ const head = positionAt(e, t, w, h);
840
+ let prevX = head.x;
841
+ let prevY = head.y;
842
+ for (let s = 1; s <= n; s++) {
843
+ const k = s / n;
844
+ const pastT = t - k * trail.length;
845
+ if (pastT < 0) break;
846
+ const pos = positionAt(e, pastT, w, h);
847
+ if (wrap2) {
848
+ const dx = pos.x - prevX;
849
+ const dy = pos.y - prevY;
850
+ if (Math.abs(dx) > w / 2 || Math.abs(dy) > h / 2) break;
851
+ }
852
+ prevX = pos.x;
853
+ prevY = pos.y;
854
+ const a = headAlpha * (1 - k * fade);
855
+ if (a <= 0) break;
856
+ const r = headSize / 2 * (1 - k * 0.7);
857
+ if (r < 0.2) break;
858
+ ctx.globalAlpha = a;
859
+ if (isSoft) {
860
+ const g = ctx.createRadialGradient(pos.x, pos.y, 0, pos.x, pos.y, r);
861
+ g.addColorStop(0, resolvedColor);
862
+ g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
863
+ g.addColorStop(1, hexToRgba(resolvedColor, 0));
864
+ ctx.fillStyle = g;
865
+ } else {
866
+ ctx.fillStyle = resolvedColor;
867
+ }
868
+ ctx.beginPath();
869
+ ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
870
+ ctx.fill();
871
+ }
872
+ }
663
873
  drawEntity(built, e, t) {
664
874
  const { ctx } = this;
665
875
  const p = positionAt(e, t, this.w, this.h);
@@ -791,11 +1001,14 @@ var SpecInstance = class {
791
1001
  const { ctx } = this;
792
1002
  ctx.globalAlpha = 1;
793
1003
  ctx.globalCompositeOperation = "source-over";
794
- this.drawBackground();
1004
+ this.drawBackground(t);
795
1005
  for (const built of this.layers) {
796
1006
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
797
1007
  this.drawLinks(built, t);
798
- for (const e of built.entities) this.drawEntity(built, e, t);
1008
+ for (const e of built.entities) {
1009
+ this.drawTrail(built, e, t);
1010
+ this.drawEntity(built, e, t);
1011
+ }
799
1012
  }
800
1013
  ctx.globalAlpha = 1;
801
1014
  ctx.globalCompositeOperation = "source-over";
@@ -837,8 +1050,9 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
837
1050
  const w = viewport.width;
838
1051
  const h = viewport.height;
839
1052
  const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
1053
+ const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
840
1054
  const rng = createRng2(spec.seed ?? 42);
841
- const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale));
1055
+ const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale, countScale));
842
1056
  let totalEntities = 0;
843
1057
  let textLayerCount = 0;
844
1058
  let motionLayerCount = 0;
@@ -847,10 +1061,16 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
847
1061
  const entities = allEntities[li];
848
1062
  totalEntities += entities.length;
849
1063
  const isText = layer.sprite.kind === "text";
850
- const isEmoji = layer.sprite.kind === "emoji";
851
1064
  const isStaticText = isText && layer.motion.type === "static";
852
1065
  if (isStaticText) textLayerCount++;
853
1066
  if (layer.motion.type !== "static") motionLayerCount++;
1067
+ if (layer.trail && layer.motion.type === "static") {
1068
+ warnings.push({
1069
+ path: `layers[${li}].trail`,
1070
+ code: "trail-on-static",
1071
+ message: "trail has no effect on static entities \u2014 they have no past positions to draw"
1072
+ });
1073
+ }
854
1074
  if (layer.sprite.kind === "circle") {
855
1075
  const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
856
1076
  const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
@@ -897,9 +1117,148 @@ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
897
1117
  });
898
1118
  }
899
1119
  }
1120
+ for (let li = 0; li < spec.layers.length; li++) {
1121
+ const layer = spec.layers[li];
1122
+ if (!layer.links) continue;
1123
+ const entities = allEntities[li];
1124
+ const positions = entities.map((e) => positionAt(e, 0, w, h));
1125
+ const maxDist = layer.links.maxDist * scale;
1126
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
1127
+ const maxPossible = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
1128
+ if (maxPossible > 0 && pairs.length / maxPossible < 0.1) {
1129
+ warnings.push({
1130
+ path: `layers[${li}].links`,
1131
+ code: "link-starvation",
1132
+ message: `only ${pairs.length}/${maxPossible} possible edges formed \u2014 raise count or widen links.maxDist`
1133
+ });
1134
+ }
1135
+ }
1136
+ for (let li = 0; li < spec.layers.length; li++) {
1137
+ const layer = spec.layers[li];
1138
+ if (layer.motion.type === "static") continue;
1139
+ const entities = allEntities[li];
1140
+ if (entities.length < 3) continue;
1141
+ const speeds = entities.map((e) => Math.sqrt(e.vx * e.vx + e.vy * e.vy));
1142
+ const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length;
1143
+ if (mean === 0) continue;
1144
+ const maxDev = Math.max(...speeds.map((s) => Math.abs(s - mean)));
1145
+ if (maxDev / mean < 0.05) {
1146
+ warnings.push({
1147
+ path: `layers[${li}].motion`,
1148
+ code: "uniform-motion",
1149
+ message: "all entities move at nearly identical speed \u2014 widen the speed range for visual variety"
1150
+ });
1151
+ }
1152
+ }
1153
+ if (totalEntities >= 8) {
1154
+ const positions = allEntities.flatMap((ents) => ents.map((e) => positionAt(e, 0, w, h)));
1155
+ const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
1156
+ const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
1157
+ const offX = Math.abs(cx / w - 0.5);
1158
+ const offY = Math.abs(cy / h - 0.5);
1159
+ if (offX > 0.2 || offY > 0.2) {
1160
+ warnings.push({
1161
+ path: "layers",
1162
+ code: "off-center",
1163
+ message: `centroid at (${(cx / w).toFixed(2)}, ${(cy / h).toFixed(2)}) \u2014 composition is heavily offset from center`
1164
+ });
1165
+ }
1166
+ }
900
1167
  return warnings;
901
1168
  }
902
1169
 
1170
+ // src/describe.ts
1171
+ import { createRng as createRng3 } from "@idle-screens/core";
1172
+ function describeScene(spec, options = {}) {
1173
+ const { width: w, height: h } = options.viewport ?? { width: 1920, height: 1080 };
1174
+ const seed = options.seed ?? spec.seed ?? 42;
1175
+ const times = options.times ?? [0, 5e3, 15e3];
1176
+ const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
1177
+ const countScale = scale > 1 ? Math.min(w, h) / LIMITS.referenceViewport : 1;
1178
+ const rng = createRng3(seed);
1179
+ const builtLayers = spec.layers.map((layer) => ({
1180
+ layer,
1181
+ entities: buildEntities(layer, rng, w, h, scale, countScale)
1182
+ }));
1183
+ const snapshots = times.map((t) => {
1184
+ const layerSnapshots = builtLayers.map(({ layer, entities }) => {
1185
+ const positions = entities.map((e) => positionAt(e, t, w, h));
1186
+ const cx = positions.length > 0 ? positions.reduce((s, p) => s + p.x, 0) / positions.length : 0;
1187
+ const cy = positions.length > 0 ? positions.reduce((s, p) => s + p.y, 0) / positions.length : 0;
1188
+ let pixArea = 0;
1189
+ for (const e of entities) {
1190
+ const r = e.size / 2;
1191
+ pixArea += layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
1192
+ }
1193
+ const coverage = pixArea / (w * h);
1194
+ const meanAlpha = entities.length > 0 ? entities.reduce((s, e) => s + e.alpha, 0) / entities.length : 0;
1195
+ let linksDrawn = null;
1196
+ let linksExpected = null;
1197
+ let connectedComponents = null;
1198
+ let isolatedNodes = null;
1199
+ if (layer.links) {
1200
+ const maxDist = layer.links.maxDist * scale;
1201
+ const pairs = linkPairs(positions, layer.links.k, maxDist, layer.wrap !== false, w, h);
1202
+ linksDrawn = pairs.length;
1203
+ linksExpected = Math.min(entities.length * layer.links.k, entities.length * (entities.length - 1) / 2);
1204
+ const parent = Array.from({ length: entities.length }, (_, i) => i);
1205
+ const find = (x) => {
1206
+ while (parent[x] !== x) {
1207
+ parent[x] = parent[parent[x]];
1208
+ x = parent[x];
1209
+ }
1210
+ return x;
1211
+ };
1212
+ for (const [a, b] of pairs) {
1213
+ const ra = find(a);
1214
+ const rb = find(b);
1215
+ if (ra !== rb) parent[ra] = rb;
1216
+ }
1217
+ const roots = new Set(entities.map((_, i) => find(i)));
1218
+ connectedComponents = roots.size;
1219
+ const degree = new Uint16Array(entities.length);
1220
+ for (const [a, b] of pairs) {
1221
+ degree[a]++;
1222
+ degree[b]++;
1223
+ }
1224
+ isolatedNodes = degree.filter((d) => d === 0).length;
1225
+ }
1226
+ return {
1227
+ key: layer.key,
1228
+ count: entities.length,
1229
+ centroid: { x: +(cx / w).toFixed(3), y: +(cy / h).toFixed(3) },
1230
+ coverage: +coverage.toFixed(4),
1231
+ meanAlpha: +meanAlpha.toFixed(3),
1232
+ linksDrawn,
1233
+ linksExpected,
1234
+ connectedComponents,
1235
+ isolatedNodes
1236
+ };
1237
+ });
1238
+ const warnings = [];
1239
+ for (const ls of layerSnapshots) {
1240
+ const label = ls.key ?? `layer`;
1241
+ if (ls.coverage * ls.meanAlpha < 5e-4) {
1242
+ warnings.push(`${label}: effectively invisible (coverage\xD7alpha=${(ls.coverage * ls.meanAlpha).toFixed(5)})`);
1243
+ }
1244
+ if (ls.linksDrawn !== null && ls.linksExpected !== null && ls.linksExpected > 0) {
1245
+ const ratio = ls.linksDrawn / ls.linksExpected;
1246
+ if (ratio < 0.1) {
1247
+ warnings.push(`${label}: link starvation \u2014 ${ls.linksDrawn}/${ls.linksExpected} edges formed`);
1248
+ }
1249
+ }
1250
+ if (ls.isolatedNodes !== null && ls.count > 0 && ls.isolatedNodes / ls.count > 0.5) {
1251
+ warnings.push(`${label}: ${ls.isolatedNodes}/${ls.count} nodes isolated \u2014 graph is disconnected`);
1252
+ }
1253
+ }
1254
+ return { t, viewport: { w, h }, layers: layerSnapshots, warnings };
1255
+ });
1256
+ return {
1257
+ spec: { id: spec.id, label: spec.label, units: spec.units ?? "px" },
1258
+ snapshots
1259
+ };
1260
+ }
1261
+
903
1262
  // src/examples/aquarium.ts
904
1263
  var AQUARIUM_SPEC = {
905
1264
  schemaVersion: 1,
@@ -932,6 +1291,53 @@ var AQUARIUM_SPEC = {
932
1291
  ]
933
1292
  };
934
1293
 
1294
+ // src/examples/comets.ts
1295
+ var COMETS_SPEC = {
1296
+ schemaVersion: 1,
1297
+ id: "comets",
1298
+ label: "Comet Shower",
1299
+ seed: 42,
1300
+ motionIntensity: "moderate",
1301
+ background: {
1302
+ type: "gradient",
1303
+ stops: [
1304
+ { at: 0, color: "#0a0015" },
1305
+ { at: 0.5, color: "#0d1b2a" },
1306
+ { at: 1, color: "#1b0a2e" }
1307
+ ],
1308
+ drift: { period: 25e3, amount: 0.12 }
1309
+ },
1310
+ layers: [
1311
+ {
1312
+ key: "stars",
1313
+ count: 120,
1314
+ sprite: { kind: "circle", radius: [0.5, 2], color: "#ffffff", soft: true },
1315
+ motion: { type: "static" },
1316
+ alpha: [0.3, 0.7],
1317
+ pulse: { amp: 0.15, period: 3e3 }
1318
+ },
1319
+ {
1320
+ key: "comets",
1321
+ count: 8,
1322
+ sprite: { kind: "circle", radius: [3, 8], color: "#88ccff", soft: true },
1323
+ motion: { type: "drift", speed: [80, 200], angle: 225 },
1324
+ alpha: [0.7, 1],
1325
+ blend: "lighter",
1326
+ trail: { length: 1500, fade: 1 }
1327
+ },
1328
+ {
1329
+ key: "fireflies",
1330
+ count: 20,
1331
+ sprite: { kind: "circle", radius: [2, 5], color: "#ffdd44", soft: true },
1332
+ motion: { type: "drift", speed: [10, 30], bidirectional: true, bob: 20 },
1333
+ alpha: [0.4, 0.8],
1334
+ pulse: { amp: 0.3, period: 2e3 },
1335
+ blend: "lighter",
1336
+ trail: { length: 800 }
1337
+ }
1338
+ ]
1339
+ };
1340
+
935
1341
  // src/examples/constellation.ts
936
1342
  var CONSTELLATION_SPEC = {
937
1343
  schemaVersion: 1,
@@ -1746,7 +2152,8 @@ var SCHEMA_EXAMPLES = [
1746
2152
  { id: "sakura", label: "Sakura Drift", spec: SAKURA_SPEC },
1747
2153
  { id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC },
1748
2154
  { id: "orrery", label: "Orrery", spec: ORRERY_SPEC },
1749
- { id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC }
2155
+ { id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC },
2156
+ { id: "comets", label: "Comet Shower", spec: COMETS_SPEC }
1750
2157
  ];
1751
2158
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
1752
2159
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -1754,6 +2161,7 @@ var EXAMPLE_BY_ID = Object.fromEntries(
1754
2161
  );
1755
2162
  export {
1756
2163
  AQUARIUM_SPEC,
2164
+ COMETS_SPEC,
1757
2165
  CONSTELLATION_SPEC,
1758
2166
  DASHBOARD_SPEC,
1759
2167
  EXAMPLE_BY_ID,
@@ -1772,6 +2180,7 @@ export {
1772
2180
  assertValidSpec,
1773
2181
  buildEntities,
1774
2182
  compileSaver,
2183
+ describeScene,
1775
2184
  easeSmooth,
1776
2185
  lerpSpec,
1777
2186
  linkPairs,
@@ -1782,6 +2191,7 @@ export {
1782
2191
  sizeAt,
1783
2192
  spriteIndexAt,
1784
2193
  spriteVariants,
2194
+ steerablePaths,
1785
2195
  structuralSignature,
1786
2196
  validateSpec
1787
2197
  };