@idle-screens/schema 2.0.0 → 2.1.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
@@ -8,8 +8,20 @@ var LIMITS = {
8
8
  // px/sec — bounds motion so nothing teleports
9
9
  maxPulseAmp: 0.5,
10
10
  // opacity breathing amplitude cap
11
- minPulsePeriod: 500
11
+ minPulsePeriod: 500,
12
12
  // ms — caps pulse at 2 Hz (WCAG flash threshold is 3 Hz)
13
+ maxSpin: 360,
14
+ // degrees/sec — one full revolution per second
15
+ maxGrowAmp: 0.8,
16
+ // size breathing amplitude cap (fraction of base size)
17
+ maxOrbitSpeed: 180,
18
+ // degrees/sec — half a revolution per second
19
+ maxLinksK: 8,
20
+ maxLinkLayerCount: 200,
21
+ minCyclePeriod: 500,
22
+ // ms — same flash-safety floor as pulse
23
+ referenceViewport: 1080
24
+ // for validating viewport-unit dimensional caps
13
25
  };
14
26
 
15
27
  // src/validate.ts
@@ -29,6 +41,9 @@ function validateSpec(spec) {
29
41
  if (spec.motionIntensity !== void 0 && !["calm", "moderate", "energetic"].includes(spec.motionIntensity)) {
30
42
  err("motionIntensity", "must be calm | moderate | energetic");
31
43
  }
44
+ if (spec.units !== void 0 && spec.units !== "px" && spec.units !== "viewport") {
45
+ err("units", "must be 'px' | 'viewport'");
46
+ }
32
47
  if (spec.background !== void 0) validateBackground(spec.background, err);
33
48
  if (!Array.isArray(spec.layers) || spec.layers.length === 0) {
34
49
  err("layers", "must be a non-empty array");
@@ -37,7 +52,7 @@ function validateSpec(spec) {
37
52
  let total = 0;
38
53
  spec.layers.forEach((layer, i) => {
39
54
  total += isObj(layer) && isNum(layer.count) ? layer.count : 0;
40
- validateLayer(layer, `layers[${i}]`, err);
55
+ validateLayer(layer, `layers[${i}]`, err, spec);
41
56
  });
42
57
  if (total > LIMITS.maxTotal) err("layers", `total entities ${total} exceeds cap ${LIMITS.maxTotal}`);
43
58
  }
@@ -70,7 +85,7 @@ function validateBackground(bg, err) {
70
85
  err("background.type", "must be solid | gradient");
71
86
  }
72
87
  }
73
- function validateLayer(layer, path, err) {
88
+ function validateLayer(layer, path, err, spec) {
74
89
  if (!isObj(layer)) return err(path, "must be an object");
75
90
  if (!isNum(layer.count) || !Number.isInteger(layer.count) || layer.count < 1) {
76
91
  err(`${path}.count`, "must be a positive integer");
@@ -120,8 +135,44 @@ function validateLayer(layer, path, err) {
120
135
  }
121
136
  }
122
137
  }
138
+ if (layer.spin !== void 0) {
139
+ if (!isNum(layer.spin)) err(`${path}.spin`, "must be a number (degrees/sec)");
140
+ else if (Math.abs(layer.spin) > LIMITS.maxSpin) err(`${path}.spin`, `must be within \xB1${LIMITS.maxSpin} deg/sec`);
141
+ }
142
+ if (layer.grow !== void 0) {
143
+ if (!isObj(layer.grow)) err(`${path}.grow`, "must be an object");
144
+ else {
145
+ if (!isNum(layer.grow.amp) || layer.grow.amp <= 0 || layer.grow.amp > LIMITS.maxGrowAmp) {
146
+ err(`${path}.grow.amp`, `must be within 0..${LIMITS.maxGrowAmp}`);
147
+ }
148
+ if (!isNum(layer.grow.period) || layer.grow.period < LIMITS.minPulsePeriod) {
149
+ err(`${path}.grow.period`, `must be >= ${LIMITS.minPulsePeriod} ms (flash-safety floor)`);
150
+ }
151
+ }
152
+ }
153
+ if (layer.links !== void 0) {
154
+ if (!isObj(layer.links)) err(`${path}.links`, "must be an object");
155
+ else {
156
+ if (!isNum(layer.links.k) || !Number.isInteger(layer.links.k) || layer.links.k < 1 || layer.links.k > LIMITS.maxLinksK) {
157
+ err(`${path}.links.k`, `must be an integer 1..${LIMITS.maxLinksK}`);
158
+ }
159
+ if (!isNum(layer.links.maxDist) || layer.links.maxDist <= 0) {
160
+ err(`${path}.links.maxDist`, "must be > 0");
161
+ }
162
+ if (layer.links.color !== void 0) color(layer.links.color, `${path}.links.color`, err);
163
+ if (layer.links.alpha !== void 0 && (!isNum(layer.links.alpha) || layer.links.alpha < 0 || layer.links.alpha > 1)) {
164
+ err(`${path}.links.alpha`, "must be 0..1");
165
+ }
166
+ if (layer.links.width !== void 0 && (!isNum(layer.links.width) || layer.links.width <= 0)) {
167
+ err(`${path}.links.width`, "must be > 0");
168
+ }
169
+ if (isNum(layer.count) && layer.count > LIMITS.maxLinkLayerCount) {
170
+ err(`${path}.links`, `layer count must be <= ${LIMITS.maxLinkLayerCount} when links is set`);
171
+ }
172
+ }
173
+ }
123
174
  validateSprite(layer.sprite, `${path}.sprite`, err);
124
- validateMotion(layer.motion, `${path}.motion`, err);
175
+ validateMotion(layer.motion, `${path}.motion`, err, spec);
125
176
  }
126
177
  function validateSprite(sprite, path, err) {
127
178
  if (!isObj(sprite)) return err(path, "must be an object");
@@ -129,6 +180,7 @@ function validateSprite(sprite, path, err) {
129
180
  if (!Array.isArray(sprite.glyphs) || sprite.glyphs.length === 0 || !sprite.glyphs.every(isStr)) {
130
181
  err(`${path}.glyphs`, "must be a non-empty array of strings");
131
182
  }
183
+ validateCycle(sprite, path, err);
132
184
  } else if (sprite.kind === "text") {
133
185
  if (!Array.isArray(sprite.strings) || sprite.strings.length === 0 || !sprite.strings.every(isStr)) {
134
186
  err(`${path}.strings`, "must be a non-empty array of strings");
@@ -143,19 +195,36 @@ function validateSprite(sprite, path, err) {
143
195
  if (sprite.maxWidth !== void 0 && (!isNum(sprite.maxWidth) || sprite.maxWidth <= 0)) {
144
196
  err(`${path}.maxWidth`, "must be a positive number");
145
197
  }
198
+ validateCycle(sprite, path, err);
146
199
  } else if (sprite.kind === "circle") {
147
200
  if (!isRange(sprite.radius) || sprite.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
148
201
  color(sprite.color, `${path}.color`, err);
149
202
  if (sprite.soft !== void 0 && typeof sprite.soft !== "boolean") err(`${path}.soft`, "must be a boolean");
203
+ if (sprite.colors !== void 0) {
204
+ if (!Array.isArray(sprite.colors) || sprite.colors.length === 0) {
205
+ err(`${path}.colors`, "must be a non-empty array of hex colours");
206
+ } else {
207
+ sprite.colors.forEach((c, ci) => color(c, `${path}.colors[${ci}]`, err));
208
+ }
209
+ }
150
210
  } else {
151
211
  err(`${path}.kind`, "must be emoji | text | circle");
152
212
  }
153
213
  }
154
- function validateMotion(motion, path, err) {
214
+ function validateCycle(sprite, path, err) {
215
+ if (sprite.cycle === void 0) return;
216
+ if (!isObj(sprite.cycle)) return err(`${path}.cycle`, "must be an object");
217
+ if (!isNum(sprite.cycle.period) || sprite.cycle.period < LIMITS.minCyclePeriod) {
218
+ err(`${path}.cycle.period`, `must be >= ${LIMITS.minCyclePeriod} ms (flash-safety floor)`);
219
+ }
220
+ }
221
+ function validateMotion(motion, path, err, spec) {
155
222
  if (!isObj(motion)) return err(path, "must be an object");
223
+ const isViewport = isObj(spec) && spec.units === "viewport";
224
+ const speedCap = isViewport ? LIMITS.maxSpeed / LIMITS.referenceViewport : LIMITS.maxSpeed;
156
225
  const speedOk = (v, p) => {
157
226
  if (!isRange(v)) err(p, "must be a [min,max] range");
158
- else if (v[1] > LIMITS.maxSpeed) err(p, `speed exceeds cap ${LIMITS.maxSpeed} px/sec`);
227
+ else if (v[1] > speedCap) err(p, `speed exceeds cap ${isViewport ? speedCap.toFixed(2) + " viewport-units/sec" : LIMITS.maxSpeed + " px/sec"}`);
159
228
  };
160
229
  if (motion.type === "drift") {
161
230
  speedOk(motion.speed, `${path}.speed`);
@@ -168,8 +237,20 @@ function validateMotion(motion, path, err) {
168
237
  } else if (motion.type === "bounce") {
169
238
  speedOk(motion.speed, `${path}.speed`);
170
239
  } else if (motion.type === "static") {
240
+ } else if (motion.type === "orbit") {
241
+ 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`);
243
+ if (!isRange(motion.radius) || motion.radius[0] <= 0) err(`${path}.radius`, "must be a [min,max] range of positive px");
244
+ if (motion.center !== void 0) {
245
+ if (!isObj(motion.center) || !isNum(motion.center.x) || !isNum(motion.center.y)) {
246
+ err(`${path}.center`, "must be {x, y} with numbers 0..1");
247
+ } else {
248
+ if (motion.center.x < 0 || motion.center.x > 1) err(`${path}.center.x`, "must be 0..1");
249
+ if (motion.center.y < 0 || motion.center.y > 1) err(`${path}.center.y`, "must be 0..1");
250
+ }
251
+ }
171
252
  } else {
172
- err(`${path}.type`, "must be drift | rise | bounce | static");
253
+ err(`${path}.type`, "must be drift | rise | bounce | static | orbit");
173
254
  }
174
255
  }
175
256
  function assertValidSpec(spec) {
@@ -187,12 +268,14 @@ function spriteVariants(sprite) {
187
268
  if (sprite.kind === "text") return sprite.strings.length;
188
269
  return 1;
189
270
  }
190
- function buildEntities(layer, rng, w, h) {
271
+ function buildEntities(layer, rng, w, h, scale = 1) {
191
272
  const [smin, smax] = layer.size ?? [20, 40];
192
273
  const variants = spriteVariants(layer.sprite);
274
+ const colorsLen = layer.sprite.kind === "circle" && layer.sprite.colors ? layer.sprite.colors.length : 0;
275
+ const cycle = layer.sprite.kind === "emoji" || layer.sprite.kind === "text" ? layer.sprite.cycle : void 0;
193
276
  const out = [];
194
277
  for (let i = 0; i < layer.count; i++) {
195
- const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 : rng.range(smin, smax);
278
+ const size = layer.sprite.kind === "circle" ? rng.range(layer.sprite.radius[0], layer.sprite.radius[1]) * 2 * scale : rng.range(smin, smax) * scale;
196
279
  let vx = 0;
197
280
  let vy = 0;
198
281
  let bob = 0;
@@ -201,22 +284,25 @@ function buildEntities(layer, rng, w, h) {
201
284
  if (m.type === "static") {
202
285
  motion = "static";
203
286
  } else if (m.type === "drift") {
204
- const s = rng.range(m.speed[0], m.speed[1]);
287
+ const s = rng.range(m.speed[0], m.speed[1]) * scale;
205
288
  let angle = (m.angle ?? 0) * Math.PI / 180;
206
289
  if (m.bidirectional && rng.next() < 0.5) angle = Math.PI - angle;
207
290
  vx = s * Math.cos(angle);
208
291
  vy = s * Math.sin(angle);
209
- bob = m.bob ?? 0;
292
+ bob = (m.bob ?? 0) * scale;
210
293
  } else if (m.type === "rise") {
211
294
  motion = "rise";
212
- vy = -rng.range(m.speed[0], m.speed[1]);
213
- bob = m.sway ?? 0;
214
- } else {
295
+ vy = -rng.range(m.speed[0], m.speed[1]) * scale;
296
+ bob = (m.sway ?? 0) * scale;
297
+ } else if (m.type === "bounce") {
215
298
  motion = "bounce";
216
- const s = rng.range(m.speed[0], m.speed[1]);
299
+ const s = rng.range(m.speed[0], m.speed[1]) * scale;
217
300
  const a = rng.range(0, Math.PI * 2);
218
301
  vx = s * Math.cos(a);
219
302
  vy = s * Math.sin(a);
303
+ } else if (m.type === "orbit") {
304
+ motion = "orbit";
305
+ vx = rng.range(m.speed[0], m.speed[1]);
220
306
  }
221
307
  let x0;
222
308
  let y0;
@@ -243,7 +329,20 @@ function buildEntities(layer, rng, w, h) {
243
329
  alpha: layer.alpha ? rng.range(layer.alpha[0], layer.alpha[1]) : 1,
244
330
  pulseAmp: layer.pulse?.amp ?? 0,
245
331
  pulsePeriod: layer.pulse?.period ?? 1e3,
246
- pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0
332
+ pulsePhase: layer.pulse ? rng.range(0, Math.PI * 2) : 0,
333
+ // New draws AFTER pulsePhase — guarded so existing specs keep identical streams.
334
+ spinSpeed: layer.spin ? layer.spin * Math.PI / 180 : 0,
335
+ // deg/sec → rad/sec
336
+ spinPhase: layer.spin ? rng.range(0, Math.PI * 2) : 0,
337
+ growAmp: layer.grow?.amp ?? 0,
338
+ growPeriod: layer.grow?.period ?? 1e3,
339
+ growPhase: layer.grow ? rng.range(0, Math.PI * 2) : 0,
340
+ orbitR: m.type === "orbit" ? rng.range(m.radius[0], m.radius[1]) * scale : 0,
341
+ orbitCx: m.type === "orbit" ? (m.center?.x ?? 0.5) * w : 0,
342
+ orbitCy: m.type === "orbit" ? (m.center?.y ?? 0.5) * h : 0,
343
+ // New draws AFTER orbit — guarded so existing specs keep identical streams.
344
+ colorIndex: colorsLen > 0 ? rng.int(0, colorsLen - 1) : -1,
345
+ cyclePeriod: cycle ? cycle.period : 0
247
346
  });
248
347
  }
249
348
  return out;
@@ -253,6 +352,15 @@ function alphaAt(e, t) {
253
352
  const a = e.alpha + e.pulseAmp * Math.sin(t * 2 * Math.PI / e.pulsePeriod + e.pulsePhase);
254
353
  return a < 0 ? 0 : a > 1 ? 1 : a;
255
354
  }
355
+ function sizeAt(e, t) {
356
+ if (!e.growAmp) return e.size;
357
+ const s = e.size * (1 + e.growAmp * Math.sin(t * 2 * Math.PI / e.growPeriod + e.growPhase));
358
+ return s > 0 ? s : 0.1;
359
+ }
360
+ function rotationAt(e, t) {
361
+ if (!e.spinSpeed) return 0;
362
+ return e.spinPhase + e.spinSpeed * (t / 1e3);
363
+ }
256
364
  function wrap(v, min, max) {
257
365
  const range = max - min;
258
366
  return ((v - min) % range + range) % range + min;
@@ -266,6 +374,14 @@ function reflect(v, min, max) {
266
374
  }
267
375
  function positionAt(e, t, w, h) {
268
376
  if (e.motion === "static") return { x: e.x0, y: e.y0, flip: false };
377
+ if (e.motion === "orbit") {
378
+ const angle = e.phase + e.vx * Math.PI / 180 * (t / 1e3);
379
+ return {
380
+ x: e.orbitCx + e.orbitR * Math.cos(angle),
381
+ y: e.orbitCy + e.orbitR * Math.sin(angle),
382
+ flip: false
383
+ };
384
+ }
269
385
  const dt = t / 1e3;
270
386
  const m = e.size;
271
387
  if (e.motion === "bounce") {
@@ -287,6 +403,43 @@ function positionAt(e, t, w, h) {
287
403
  if (e.bob) y += e.bob * Math.sin(t / 500 + e.phase);
288
404
  return { x, y, flip: e.headingLeft };
289
405
  }
406
+ function spriteIndexAt(e, t, variants) {
407
+ if (!e.cyclePeriod || variants <= 1) return e.spriteIndex;
408
+ return Math.floor(t / e.cyclePeriod + e.phase / (2 * Math.PI)) % variants;
409
+ }
410
+ function linkPairs(positions, k, maxDist, doWrap, w, h) {
411
+ const n = positions.length;
412
+ const edges = /* @__PURE__ */ new Set();
413
+ const out = [];
414
+ for (let i = 0; i < n; i++) {
415
+ const pi = positions[i];
416
+ const neighbors = [];
417
+ for (let j = 0; j < n; j++) {
418
+ if (i === j) continue;
419
+ const pj = positions[j];
420
+ let dx = pj.x - pi.x;
421
+ let dy = pj.y - pi.y;
422
+ if (doWrap) {
423
+ if (Math.abs(dx) > w / 2) dx = dx > 0 ? dx - w : dx + w;
424
+ if (Math.abs(dy) > h / 2) dy = dy > 0 ? dy - h : dy + h;
425
+ }
426
+ const dist = Math.sqrt(dx * dx + dy * dy);
427
+ if (dist <= maxDist) neighbors.push({ dist, j });
428
+ }
429
+ neighbors.sort((a, b) => a.dist - b.dist || a.j - b.j);
430
+ for (let ni = 0; ni < Math.min(k, neighbors.length); ni++) {
431
+ const j = neighbors[ni].j;
432
+ const lo = Math.min(i, j);
433
+ const hi = Math.max(i, j);
434
+ const key = `${lo}:${hi}`;
435
+ if (!edges.has(key)) {
436
+ edges.add(key);
437
+ out.push([lo, hi]);
438
+ }
439
+ }
440
+ }
441
+ return out;
442
+ }
290
443
 
291
444
  // src/compile.ts
292
445
  import {
@@ -365,7 +518,8 @@ function easeSmooth(k) {
365
518
  return c * c * (3 - 2 * c);
366
519
  }
367
520
  function structuralSignature(spec) {
368
- return JSON.stringify(
521
+ return JSON.stringify([
522
+ spec.units,
369
523
  spec.layers.map((l) => [
370
524
  l.count,
371
525
  l.size,
@@ -376,11 +530,14 @@ function structuralSignature(spec) {
376
530
  l.flip,
377
531
  l.alpha,
378
532
  l.pulse,
533
+ l.spin,
534
+ l.grow,
379
535
  l.sprite.kind,
380
536
  l.sprite.kind === "circle" ? l.sprite.radius : void 0,
381
- l.sprite.kind === "emoji" ? l.sprite.glyphs.length : l.sprite.kind === "text" ? l.sprite.strings.length : void 0
537
+ l.sprite.kind === "circle" ? l.sprite.colors?.length : void 0,
538
+ l.sprite.kind === "emoji" ? [l.sprite.glyphs.length, l.sprite.cycle?.period] : l.sprite.kind === "text" ? [l.sprite.strings.length, l.sprite.cycle?.period] : void 0
382
539
  ])
383
- );
540
+ ]);
384
541
  }
385
542
 
386
543
  // src/compile.ts
@@ -463,7 +620,8 @@ var SpecInstance = class {
463
620
  /** (Re)seed and place all entities — deterministic for the seed + size. */
464
621
  rebuild() {
465
622
  const rng = createRng(this.seed);
466
- this.layers = this.effSpec.layers.map((layer) => ({ layer, entities: buildEntities(layer, rng, this.w, this.h) }));
623
+ 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) }));
467
625
  this.lastStructural = structuralSignature(this.effSpec);
468
626
  }
469
627
  start() {
@@ -498,45 +656,60 @@ var SpecInstance = class {
498
656
  ctx.fillRect(0, 0, w, h);
499
657
  if (bg.band) {
500
658
  ctx.fillStyle = bg.band.color;
501
- ctx.fillRect(0, h - bg.band.height, w, bg.band.height);
659
+ const bh = bg.band.height * (this.effSpec.units === "viewport" ? Math.min(w, h) : 1);
660
+ ctx.fillRect(0, h - bh, w, bh);
502
661
  }
503
662
  }
504
663
  drawEntity(built, e, t) {
505
664
  const { ctx } = this;
506
665
  const p = positionAt(e, t, this.w, this.h);
507
666
  const sprite = built.layer.sprite;
667
+ const sz = sizeAt(e, t);
668
+ const rot = rotationAt(e, t);
508
669
  ctx.globalAlpha = alphaAt(e, t);
509
670
  if (sprite.kind === "circle") {
510
- const r = e.size / 2;
671
+ const r = sz / 2;
672
+ const resolvedColor = sprite.colors?.[e.colorIndex] ?? sprite.color;
673
+ ctx.save();
674
+ if (rot) {
675
+ ctx.translate(p.x, p.y);
676
+ ctx.rotate(rot);
677
+ ctx.translate(-p.x, -p.y);
678
+ }
511
679
  if (sprite.soft) {
512
680
  const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
513
- g.addColorStop(0, sprite.color);
514
- g.addColorStop(0.35, hexToRgba(sprite.color, 0.75));
515
- g.addColorStop(1, hexToRgba(sprite.color, 0));
681
+ g.addColorStop(0, resolvedColor);
682
+ g.addColorStop(0.35, hexToRgba(resolvedColor, 0.75));
683
+ g.addColorStop(1, hexToRgba(resolvedColor, 0));
516
684
  ctx.fillStyle = g;
517
685
  } else {
518
- ctx.fillStyle = sprite.color;
686
+ ctx.fillStyle = resolvedColor;
519
687
  }
520
688
  ctx.beginPath();
521
689
  ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
522
690
  ctx.fill();
691
+ ctx.restore();
523
692
  return;
524
693
  }
525
694
  ctx.save();
526
695
  ctx.translate(p.x, p.y);
696
+ if (rot) ctx.rotate(rot);
527
697
  if (p.flip && built.layer.flip) ctx.scale(-1, 1);
528
698
  ctx.textAlign = "center";
529
699
  ctx.textBaseline = "middle";
530
700
  if (sprite.kind === "emoji") {
531
- ctx.font = `${e.size}px serif`;
532
- ctx.fillText(sprite.glyphs[e.spriteIndex] ?? sprite.glyphs[0], 0, 0);
701
+ const idx = spriteIndexAt(e, t, sprite.glyphs.length);
702
+ ctx.font = `${sz}px serif`;
703
+ ctx.fillText(sprite.glyphs[idx] ?? sprite.glyphs[0], 0, 0);
533
704
  } else {
534
705
  ctx.textAlign = sprite.align ?? "center";
535
706
  ctx.textBaseline = sprite.baseline ?? "middle";
536
- ctx.font = sprite.font ?? `${e.size}px system-ui, sans-serif`;
707
+ ctx.font = sprite.font ?? `${sz}px system-ui, sans-serif`;
537
708
  ctx.fillStyle = sprite.color ?? "#e6e8ef";
538
- const text = sprite.strings[e.spriteIndex] ?? sprite.strings[0];
539
- if (sprite.maxWidth) ctx.fillText(text, 0, 0, sprite.maxWidth);
709
+ const idx = spriteIndexAt(e, t, sprite.strings.length);
710
+ const text = sprite.strings[idx] ?? sprite.strings[0];
711
+ const mw = sprite.maxWidth ? sprite.maxWidth * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1) : void 0;
712
+ if (mw) ctx.fillText(text, 0, 0, mw);
540
713
  else ctx.fillText(text, 0, 0);
541
714
  }
542
715
  ctx.restore();
@@ -579,6 +752,39 @@ var SpecInstance = class {
579
752
  this.renderFrame(this.lastT, this.seed);
580
753
  }
581
754
  }
755
+ drawLinks(built, t) {
756
+ const { links } = built.layer;
757
+ if (!links) return;
758
+ const { ctx } = this;
759
+ const wrap2 = built.layer.wrap !== false;
760
+ const positions = built.entities.map((e) => positionAt(e, t, this.w, this.h));
761
+ const pairs = linkPairs(positions, links.k, links.maxDist * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1), wrap2, this.w, this.h);
762
+ const lw = (links.width ?? 1) * (this.effSpec.units === "viewport" ? Math.min(this.w, this.h) : 1);
763
+ ctx.lineWidth = lw;
764
+ for (const [i, j] of pairs) {
765
+ const pi = positions[i];
766
+ const pj = positions[j];
767
+ const ei = built.entities[i];
768
+ let resolvedColor = links.color;
769
+ if (!resolvedColor) {
770
+ const sprite = built.layer.sprite;
771
+ if (sprite.kind === "circle") resolvedColor = sprite.colors?.[ei.colorIndex] ?? sprite.color;
772
+ else resolvedColor = "#e6e8ef";
773
+ }
774
+ ctx.globalAlpha = links.alpha ?? alphaAt(ei, t);
775
+ ctx.strokeStyle = resolvedColor;
776
+ let dx = pj.x - pi.x;
777
+ let dy = pj.y - pi.y;
778
+ if (wrap2) {
779
+ if (Math.abs(dx) > this.w / 2) dx = dx > 0 ? dx - this.w : dx + this.w;
780
+ if (Math.abs(dy) > this.h / 2) dy = dy > 0 ? dy - this.h : dy + this.h;
781
+ }
782
+ ctx.beginPath();
783
+ ctx.moveTo(pi.x, pi.y);
784
+ ctx.lineTo(pi.x + dx, pi.y + dy);
785
+ ctx.stroke();
786
+ }
787
+ }
582
788
  /** Deterministic, frame-addressable render (shared by the rAF loop). */
583
789
  renderFrame(t, _seed) {
584
790
  this.stepTransition(t);
@@ -588,6 +794,7 @@ var SpecInstance = class {
588
794
  this.drawBackground();
589
795
  for (const built of this.layers) {
590
796
  ctx.globalCompositeOperation = built.layer.blend ?? "source-over";
797
+ this.drawLinks(built, t);
591
798
  for (const e of built.entities) this.drawEntity(built, e, t);
592
799
  }
593
800
  ctx.globalAlpha = 1;
@@ -623,6 +830,76 @@ function compileSaver(spec) {
623
830
  };
624
831
  }
625
832
 
833
+ // src/advise.ts
834
+ import { createRng as createRng2 } from "@idle-screens/core";
835
+ function adviseSpec(spec, viewport = { width: 1920, height: 1080 }) {
836
+ const warnings = [];
837
+ const w = viewport.width;
838
+ const h = viewport.height;
839
+ const scale = spec.units === "viewport" ? Math.min(w, h) : 1;
840
+ const rng = createRng2(spec.seed ?? 42);
841
+ const allEntities = spec.layers.map((l) => buildEntities(l, rng, w, h, scale));
842
+ let totalEntities = 0;
843
+ let textLayerCount = 0;
844
+ let motionLayerCount = 0;
845
+ for (let li = 0; li < spec.layers.length; li++) {
846
+ const layer = spec.layers[li];
847
+ const entities = allEntities[li];
848
+ totalEntities += entities.length;
849
+ const isText = layer.sprite.kind === "text";
850
+ const isEmoji = layer.sprite.kind === "emoji";
851
+ const isStaticText = isText && layer.motion.type === "static";
852
+ if (isStaticText) textLayerCount++;
853
+ if (layer.motion.type !== "static") motionLayerCount++;
854
+ if (layer.sprite.kind === "circle") {
855
+ const maxR = entities.reduce((m, e) => Math.max(m, e.size / 2), 0);
856
+ const maxAlpha = entities.reduce((m, e) => Math.max(m, e.alpha), 0);
857
+ if (maxR * maxAlpha < 0.05) {
858
+ warnings.push({
859
+ path: `layers[${li}]`,
860
+ code: "invisible-layer",
861
+ message: `layer's max visible radius (${maxR.toFixed(1)}px \xD7 ${maxAlpha.toFixed(2)} alpha) is below perceptual floor`
862
+ });
863
+ }
864
+ }
865
+ }
866
+ let totalCoverage = 0;
867
+ for (let li = 0; li < spec.layers.length; li++) {
868
+ const layer = spec.layers[li];
869
+ const entities = allEntities[li];
870
+ for (const e of entities) {
871
+ const r = e.size / 2;
872
+ const pixArea = layer.sprite.kind === "circle" ? Math.PI * r * r : e.size * e.size;
873
+ totalCoverage += pixArea * e.alpha / (w * h);
874
+ }
875
+ }
876
+ if (totalCoverage < 5e-4 && spec.layers.length > 0) {
877
+ warnings.push({
878
+ path: "layers",
879
+ code: "sparse-scene",
880
+ message: `alpha-weighted coverage is ${(totalCoverage * 100).toFixed(4)}% \u2014 scene will look empty on most displays`
881
+ });
882
+ }
883
+ if (totalEntities > 500) {
884
+ warnings.push({
885
+ path: "layers",
886
+ code: "dense-scene",
887
+ message: `${totalEntities} entities \u2014 scene may feel crowded and hurt performance on low-end devices`
888
+ });
889
+ }
890
+ if (spec.layers.length > 0 && textLayerCount > 0 && motionLayerCount === 0) {
891
+ const textRatio = textLayerCount / spec.layers.length;
892
+ if (textRatio >= 0.8) {
893
+ warnings.push({
894
+ path: "layers",
895
+ code: "text-heavy",
896
+ message: "most layers are static text \u2014 reads as a document, not a screensaver"
897
+ });
898
+ }
899
+ }
900
+ return warnings;
901
+ }
902
+
626
903
  // src/examples/aquarium.ts
627
904
  var AQUARIUM_SPEC = {
628
905
  schemaVersion: 1,
@@ -655,6 +932,51 @@ var AQUARIUM_SPEC = {
655
932
  ]
656
933
  };
657
934
 
935
+ // src/examples/constellation.ts
936
+ var CONSTELLATION_SPEC = {
937
+ schemaVersion: 1,
938
+ id: "constellation",
939
+ label: "Constellation",
940
+ seed: 73,
941
+ motionIntensity: "calm",
942
+ background: {
943
+ type: "gradient",
944
+ stops: [
945
+ { at: 0, color: "#020212" },
946
+ { at: 0.5, color: "#050520" },
947
+ { at: 1, color: "#0a0830" }
948
+ ]
949
+ },
950
+ layers: [
951
+ // Distant static stars (no links, just atmosphere)
952
+ {
953
+ count: 100,
954
+ sprite: { kind: "circle", radius: [1, 3], color: "#667799", colors: ["#8899cc", "#99aadd", "#aabbee", "#7788aa"] },
955
+ alpha: [0.2, 0.7],
956
+ pulse: { amp: 0.2, period: 3e3 },
957
+ motion: { type: "static" }
958
+ },
959
+ // Connected constellation nodes — slow drift, linked
960
+ {
961
+ key: "nodes",
962
+ count: 40,
963
+ sprite: { kind: "circle", radius: [2, 5], color: "#88aaff", colors: ["#88aaff", "#aaccff", "#ffcc88", "#ff8866"], soft: true },
964
+ alpha: [0.5, 0.9],
965
+ blend: "lighter",
966
+ motion: { type: "drift", speed: [2, 8], bidirectional: true, bob: 3 },
967
+ links: { k: 3, maxDist: 200, alpha: 0.15, width: 0.5 }
968
+ },
969
+ // Cycling asterism markers
970
+ {
971
+ count: 6,
972
+ sprite: { kind: "emoji", glyphs: ["\u2726", "\u2727", "\u22C6", "\u2217"], cycle: { period: 2e3 } },
973
+ size: [14, 22],
974
+ alpha: [0.4, 0.7],
975
+ motion: { type: "drift", speed: [1, 4], bidirectional: true }
976
+ }
977
+ ]
978
+ };
979
+
658
980
  // src/examples/dev-dashboard.ts
659
981
  var DASHBOARD_SPEC = {
660
982
  schemaVersion: 1,
@@ -1219,6 +1541,90 @@ var LANTERNS_SPEC = {
1219
1541
  ]
1220
1542
  };
1221
1543
 
1544
+ // src/examples/orrery.ts
1545
+ var ORRERY_SPEC = {
1546
+ schemaVersion: 1,
1547
+ id: "orrery",
1548
+ label: "Orrery",
1549
+ seed: 7,
1550
+ motionIntensity: "calm",
1551
+ background: {
1552
+ type: "gradient",
1553
+ stops: [
1554
+ { at: 0, color: "#020210" },
1555
+ { at: 0.5, color: "#06051a" },
1556
+ { at: 1, color: "#0a0820" }
1557
+ ]
1558
+ },
1559
+ layers: [
1560
+ // Distant stars
1561
+ {
1562
+ count: 80,
1563
+ sprite: { kind: "circle", radius: [0.3, 1.2], color: "#8899cc" },
1564
+ alpha: [0.2, 0.8],
1565
+ pulse: { amp: 0.3, period: 3e3 },
1566
+ motion: { type: "static" }
1567
+ },
1568
+ // Sun corona (soft glow)
1569
+ {
1570
+ key: "corona",
1571
+ count: 1,
1572
+ sprite: { kind: "circle", radius: [60, 60], color: "#ffaa22", soft: true },
1573
+ blend: "lighter",
1574
+ alpha: [0.6, 0.6],
1575
+ grow: { amp: 0.15, period: 4e3 },
1576
+ motion: { type: "static" },
1577
+ position: { x: 0.5, y: 0.5 }
1578
+ },
1579
+ // Sun body
1580
+ {
1581
+ key: "sun",
1582
+ count: 1,
1583
+ sprite: { kind: "emoji", glyphs: ["\u2600\uFE0F"] },
1584
+ size: [48, 48],
1585
+ grow: { amp: 0.08, period: 4e3 },
1586
+ motion: { type: "static" },
1587
+ position: { x: 0.5, y: 0.5 }
1588
+ },
1589
+ // Inner ring — small fast planets
1590
+ {
1591
+ key: "inner",
1592
+ count: 3,
1593
+ sprite: { kind: "emoji", glyphs: ["\u{1FAA8}", "\u{1F534}", "\u{1F7E0}"] },
1594
+ size: [16, 22],
1595
+ spin: 30,
1596
+ motion: { type: "orbit", speed: [28, 42], radius: [80, 120] }
1597
+ },
1598
+ // Middle ring — gas giants
1599
+ {
1600
+ key: "middle",
1601
+ count: 2,
1602
+ sprite: { kind: "emoji", glyphs: ["\u{1F7E4}", "\u{1FA90}"] },
1603
+ size: [28, 38],
1604
+ spin: -15,
1605
+ motion: { type: "orbit", speed: [12, 20], radius: [160, 200] }
1606
+ },
1607
+ // Outer ring — ice giants
1608
+ {
1609
+ key: "outer",
1610
+ count: 2,
1611
+ sprite: { kind: "emoji", glyphs: ["\u{1F535}", "\u{1F48E}"] },
1612
+ size: [22, 30],
1613
+ spin: 10,
1614
+ motion: { type: "orbit", speed: [5, 10], radius: [240, 300] }
1615
+ },
1616
+ // Orbital dust ring (soft glow particles orbiting slowly)
1617
+ {
1618
+ count: 24,
1619
+ sprite: { kind: "circle", radius: [1, 3], color: "#ccaa66", soft: true },
1620
+ alpha: [0.15, 0.4],
1621
+ blend: "lighter",
1622
+ pulse: { amp: 0.2, period: 2500 },
1623
+ motion: { type: "orbit", speed: [2, 8], radius: [100, 320] }
1624
+ }
1625
+ ]
1626
+ };
1627
+
1222
1628
  // src/examples/rain.ts
1223
1629
  var RAIN_SPEC = {
1224
1630
  schemaVersion: 1,
@@ -1338,7 +1744,9 @@ var SCHEMA_EXAMPLES = [
1338
1744
  { id: "snowfall", label: "Snowfall", spec: SNOWFALL_SPEC },
1339
1745
  { id: "lanterns", label: "Night Lanterns", spec: LANTERNS_SPEC },
1340
1746
  { id: "sakura", label: "Sakura Drift", spec: SAKURA_SPEC },
1341
- { id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC }
1747
+ { id: "dev-dashboard", label: "Dev Dashboard", spec: DASHBOARD_SPEC },
1748
+ { id: "orrery", label: "Orrery", spec: ORRERY_SPEC },
1749
+ { id: "constellation", label: "Constellation", spec: CONSTELLATION_SPEC }
1342
1750
  ];
1343
1751
  var EXAMPLE_SPECS = SCHEMA_EXAMPLES.map((e) => e.spec);
1344
1752
  var EXAMPLE_BY_ID = Object.fromEntries(
@@ -1346,16 +1754,19 @@ var EXAMPLE_BY_ID = Object.fromEntries(
1346
1754
  );
1347
1755
  export {
1348
1756
  AQUARIUM_SPEC,
1757
+ CONSTELLATION_SPEC,
1349
1758
  DASHBOARD_SPEC,
1350
1759
  EXAMPLE_BY_ID,
1351
1760
  EXAMPLE_SPECS,
1352
1761
  LANTERNS_SPEC,
1353
1762
  LIMITS,
1763
+ ORRERY_SPEC,
1354
1764
  RAIN_SPEC,
1355
1765
  SAKURA_SPEC,
1356
1766
  SCHEMA_EXAMPLES,
1357
1767
  SCHEMA_VERSION,
1358
1768
  SNOWFALL_SPEC,
1769
+ adviseSpec,
1359
1770
  alphaAt,
1360
1771
  applyDeltasToSpec,
1361
1772
  assertValidSpec,
@@ -1363,9 +1774,13 @@ export {
1363
1774
  compileSaver,
1364
1775
  easeSmooth,
1365
1776
  lerpSpec,
1777
+ linkPairs,
1366
1778
  manifestFor,
1367
1779
  positionAt,
1368
1780
  resolveSpecPath,
1781
+ rotationAt,
1782
+ sizeAt,
1783
+ spriteIndexAt,
1369
1784
  spriteVariants,
1370
1785
  structuralSignature,
1371
1786
  validateSpec