@basmilius/sparkle 2.2.0 → 2.4.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@basmilius/sparkle",
3
3
  "license": "MIT",
4
- "version": "2.2.0",
4
+ "version": "2.4.0",
5
5
  "author": {
6
6
  "email": "bas@mili.us",
7
7
  "name": "Bas Milius",
package/src/canvas.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export class LimitedFrameRateCanvas {
2
2
  static #globalSpeed: number = 1;
3
+ static #globalFrameRate: number | null = null;
4
+ static #showFps: boolean = false;
3
5
 
4
6
  static get globalSpeed(): number {
5
7
  return LimitedFrameRateCanvas.#globalSpeed;
@@ -9,6 +11,28 @@ export class LimitedFrameRateCanvas {
9
11
  LimitedFrameRateCanvas.#globalSpeed = value;
10
12
  }
11
13
 
14
+ /**
15
+ * Global frame rate override for all canvas instances.
16
+ * null = use each instance's own frame rate.
17
+ * 0 = unlimited (render as fast as the browser allows).
18
+ * Any positive number = cap at that many frames per second.
19
+ */
20
+ static get globalFrameRate(): number | null {
21
+ return LimitedFrameRateCanvas.#globalFrameRate;
22
+ }
23
+
24
+ static set globalFrameRate(value: number | null) {
25
+ LimitedFrameRateCanvas.#globalFrameRate = value;
26
+ }
27
+
28
+ static get showFps(): boolean {
29
+ return LimitedFrameRateCanvas.#showFps;
30
+ }
31
+
32
+ static set showFps(value: boolean) {
33
+ LimitedFrameRateCanvas.#showFps = value;
34
+ }
35
+
12
36
  readonly #canvas: HTMLCanvasElement;
13
37
  readonly #context: CanvasRenderingContext2D;
14
38
  readonly #frameRate: number;
@@ -23,6 +47,9 @@ export class LimitedFrameRateCanvas {
23
47
  #isStopped: boolean = true;
24
48
  #height: number = 540;
25
49
  #width: number = 960;
50
+ #fps: string = '0.0';
51
+ #fpsFrames: number = 0;
52
+ #fpsTime: number = 0;
26
53
 
27
54
  get canvas(): HTMLCanvasElement {
28
55
  return this.#canvas;
@@ -48,6 +75,10 @@ export class LimitedFrameRateCanvas {
48
75
  this.#speed = value;
49
76
  }
50
77
 
78
+ get dpr(): number {
79
+ return devicePixelRatio;
80
+ }
81
+
51
82
  get frameRate(): number {
52
83
  return this.#frameRate;
53
84
  }
@@ -76,7 +107,7 @@ export class LimitedFrameRateCanvas {
76
107
  this.#canvas = canvas;
77
108
  this.#context = canvas.getContext('2d', options);
78
109
  this.#frameRate = frameRate;
79
- this.#target = 1000 / frameRate;
110
+ this.#target = frameRate > 0 ? 1000 / frameRate : 0;
80
111
 
81
112
  this.onVisibilityChange = this.onVisibilityChange.bind(this);
82
113
  this.onResize = this.onResize.bind(this);
@@ -93,7 +124,10 @@ export class LimitedFrameRateCanvas {
93
124
  this.#current = Date.now();
94
125
  this.#frame = requestAnimationFrame(this.loop.bind(this));
95
126
 
96
- if (this.#then > 0 && this.#current - this.#then + 1 < this.#target) {
127
+ const globalRate = LimitedFrameRateCanvas.#globalFrameRate;
128
+ const effectiveTarget = globalRate !== null ? (globalRate > 0 ? 1000 / globalRate : 0) : this.#target;
129
+
130
+ if (effectiveTarget > 0 && this.#then > 0 && this.#current - this.#then + 1 < effectiveTarget) {
97
131
  return;
98
132
  }
99
133
 
@@ -105,6 +139,24 @@ export class LimitedFrameRateCanvas {
105
139
  this.tick();
106
140
  this.draw();
107
141
 
142
+ if (LimitedFrameRateCanvas.#showFps) {
143
+ ++this.#fpsFrames;
144
+
145
+ if (this.#fpsTime === 0) {
146
+ this.#fpsTime = this.#current;
147
+ } else {
148
+ const elapsed = this.#current - this.#fpsTime;
149
+
150
+ if (elapsed >= 1000) {
151
+ this.#fps = (Math.round(this.#fpsFrames * 10000 / elapsed) / 10).toFixed(1);
152
+ this.#fpsFrames = 0;
153
+ this.#fpsTime = this.#current;
154
+ }
155
+ }
156
+
157
+ this.#drawFps();
158
+ }
159
+
108
160
  this.#then = this.#now;
109
161
  }
110
162
 
@@ -132,6 +184,32 @@ export class LimitedFrameRateCanvas {
132
184
  }
133
185
  }
134
186
 
187
+ #drawFps(): void {
188
+ const ctx = this.#context;
189
+ const text = `${this.#fps} FPS`;
190
+ const x = 9;
191
+ const y = 9;
192
+ const paddingX = 6;
193
+ const paddingY = 4;
194
+
195
+ ctx.save();
196
+ ctx.font = '700 10px ui-monospace, monospace';
197
+
198
+ const textWidth = ctx.measureText(text).width;
199
+ const boxWidth = textWidth + paddingX * 2;
200
+ const boxHeight = 11 + paddingY * 2;
201
+
202
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
203
+ ctx.beginPath();
204
+ ctx.roundRect(x, y, boxWidth, boxHeight, 3);
205
+ ctx.fill();
206
+
207
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
208
+ ctx.textBaseline = 'middle';
209
+ ctx.fillText(text, x + paddingX, y + boxHeight / 1.9);
210
+ ctx.restore();
211
+ }
212
+
135
213
  draw(): void {
136
214
  throw new Error('LimitedFrameRateCanvas::draw() should be overwritten.');
137
215
  }
@@ -2,103 +2,90 @@ import type { Shape } from './types';
2
2
 
3
3
  const TWO_PI = Math.PI * 2;
4
4
 
5
- export const SHAPE_PATHS: Record<Shape, Path2D> = {
6
- bowtie: (() => {
7
- const path = new Path2D();
8
- path.moveTo(-1, -0.7);
9
- path.lineTo(0, 0);
10
- path.lineTo(-1, 0.7);
11
- path.closePath();
12
- path.moveTo(1, -0.7);
13
- path.lineTo(0, 0);
14
- path.lineTo(1, 0.7);
15
- path.closePath();
16
- return path;
17
- })(),
18
- circle: (() => {
19
- const path = new Path2D();
20
- path.ellipse(0, 0, 0.6, 1, 0, 0, TWO_PI);
21
- return path;
22
- })(),
23
- crescent: (() => {
24
- const path = new Path2D();
25
- path.arc(0, 0, 1, 0, TWO_PI, false);
26
- path.arc(0.45, 0, 0.9, 0, TWO_PI, true);
27
- return path;
28
- })(),
29
- diamond: (() => {
30
- const path = new Path2D();
31
- path.moveTo(0, -1);
32
- path.lineTo(0.6, 0);
33
- path.lineTo(0, 1);
34
- path.lineTo(-0.6, 0);
35
- path.closePath();
36
- return path;
37
- })(),
38
- heart: (() => {
39
- const path = new Path2D();
40
- path.moveTo(0, 1);
41
- path.bezierCurveTo(-0.4, 0.55, -1, 0.1, -1, -0.35);
42
- path.bezierCurveTo(-1, -0.8, -0.5, -1, 0, -0.6);
43
- path.bezierCurveTo(0.5, -1, 1, -0.8, 1, -0.35);
44
- path.bezierCurveTo(1, 0.1, 0.4, 0.55, 0, 1);
45
- path.closePath();
46
- return path;
47
- })(),
48
- hexagon: (() => {
49
- const path = new Path2D();
50
- for (let i = 0; i < 6; i++) {
51
- const angle = (i * Math.PI / 3) - Math.PI / 2;
52
- if (i === 0) {
53
- path.moveTo(Math.cos(angle), Math.sin(angle));
54
- } else {
55
- path.lineTo(Math.cos(angle), Math.sin(angle));
56
- }
5
+ function buildShapePaths(): Record<Shape, Path2D> {
6
+ const bowtie = new Path2D();
7
+ bowtie.moveTo(-1, -0.7);
8
+ bowtie.lineTo(0, 0);
9
+ bowtie.lineTo(-1, 0.7);
10
+ bowtie.closePath();
11
+ bowtie.moveTo(1, -0.7);
12
+ bowtie.lineTo(0, 0);
13
+ bowtie.lineTo(1, 0.7);
14
+ bowtie.closePath();
15
+
16
+ const circle = new Path2D();
17
+ circle.ellipse(0, 0, 0.6, 1, 0, 0, TWO_PI);
18
+
19
+ const crescent = new Path2D();
20
+ crescent.arc(0, 0, 1, 0, TWO_PI, false);
21
+ crescent.arc(0.45, 0, 0.9, 0, TWO_PI, true);
22
+
23
+ const diamond = new Path2D();
24
+ diamond.moveTo(0, -1);
25
+ diamond.lineTo(0.6, 0);
26
+ diamond.lineTo(0, 1);
27
+ diamond.lineTo(-0.6, 0);
28
+ diamond.closePath();
29
+
30
+ const heart = new Path2D();
31
+ heart.moveTo(0, 1);
32
+ heart.bezierCurveTo(-0.4, 0.55, -1, 0.1, -1, -0.35);
33
+ heart.bezierCurveTo(-1, -0.8, -0.5, -1, 0, -0.6);
34
+ heart.bezierCurveTo(0.5, -1, 1, -0.8, 1, -0.35);
35
+ heart.bezierCurveTo(1, 0.1, 0.4, 0.55, 0, 1);
36
+ heart.closePath();
37
+
38
+ const hexagon = new Path2D();
39
+ for (let i = 0; i < 6; i++) {
40
+ const angle = (i * Math.PI / 3) - Math.PI / 2;
41
+ if (i === 0) {
42
+ hexagon.moveTo(Math.cos(angle), Math.sin(angle));
43
+ } else {
44
+ hexagon.lineTo(Math.cos(angle), Math.sin(angle));
57
45
  }
58
- path.closePath();
59
- return path;
60
- })(),
61
- ribbon: (() => {
62
- const path = new Path2D();
63
- path.rect(-0.2, -1, 0.4, 2);
64
- return path;
65
- })(),
66
- ring: (() => {
67
- const path = new Path2D();
68
- path.arc(0, 0, 1, 0, TWO_PI, false);
69
- path.arc(0, 0, 0.55, 0, TWO_PI, true);
70
- return path;
71
- })(),
72
- square: (() => {
73
- const path = new Path2D();
74
- path.rect(-0.7, -0.7, 1.4, 1.4);
75
- return path;
76
- })(),
77
- star: (() => {
78
- const path = new Path2D();
79
- for (let i = 0; i < 10; i++) {
80
- const r = i % 2 === 0 ? 1 : 0.42;
81
- const angle = (i * Math.PI / 5) - Math.PI / 2;
82
- if (i === 0) {
83
- path.moveTo(r * Math.cos(angle), r * Math.sin(angle));
84
- } else {
85
- path.lineTo(r * Math.cos(angle), r * Math.sin(angle));
86
- }
46
+ }
47
+ hexagon.closePath();
48
+
49
+ const ribbon = new Path2D();
50
+ ribbon.rect(-0.2, -1, 0.4, 2);
51
+
52
+ const ring = new Path2D();
53
+ ring.arc(0, 0, 1, 0, TWO_PI, false);
54
+ ring.arc(0, 0, 0.55, 0, TWO_PI, true);
55
+
56
+ const square = new Path2D();
57
+ square.rect(-0.7, -0.7, 1.4, 1.4);
58
+
59
+ const star = new Path2D();
60
+ for (let i = 0; i < 10; i++) {
61
+ const r = i % 2 === 0 ? 1 : 0.42;
62
+ const angle = (i * Math.PI / 5) - Math.PI / 2;
63
+ if (i === 0) {
64
+ star.moveTo(r * Math.cos(angle), r * Math.sin(angle));
65
+ } else {
66
+ star.lineTo(r * Math.cos(angle), r * Math.sin(angle));
87
67
  }
88
- path.closePath();
89
- return path;
90
- })(),
91
- triangle: (() => {
92
- const path = new Path2D();
93
- for (let i = 0; i < 3; i++) {
94
- const angle = (i * 2 * Math.PI / 3) - Math.PI / 2;
95
- if (i === 0) {
96
- path.moveTo(Math.cos(angle), Math.sin(angle));
97
- } else {
98
- path.lineTo(Math.cos(angle), Math.sin(angle));
99
- }
68
+ }
69
+ star.closePath();
70
+
71
+ const triangle = new Path2D();
72
+ for (let i = 0; i < 3; i++) {
73
+ const angle = (i * 2 * Math.PI / 3) - Math.PI / 2;
74
+ if (i === 0) {
75
+ triangle.moveTo(Math.cos(angle), Math.sin(angle));
76
+ } else {
77
+ triangle.lineTo(Math.cos(angle), Math.sin(angle));
100
78
  }
101
- path.closePath();
102
- return path;
103
- })()
104
- };
79
+ }
80
+ triangle.closePath();
81
+
82
+ return {bowtie, circle, crescent, diamond, heart, hexagon, ribbon, ring, square, star, triangle};
83
+ }
84
+
85
+ let _shapePaths: Record<Shape, Path2D> | null = null;
86
+
87
+ export const SHAPE_PATHS: Record<Shape, Path2D> = new Proxy({} as Record<Shape, Path2D>, {
88
+ get(_, key: string) {
89
+ return (_shapePaths ??= buildShapePaths())[key as Shape];
90
+ }
91
+ });
package/src/effect.ts CHANGED
@@ -50,7 +50,7 @@ export abstract class Effect<TConfig = Record<string, unknown>> implements Simul
50
50
  * Mount this effect to a canvas element or CSS selector, creating the render loop.
51
51
  * Must be called before start().
52
52
  */
53
- mount(canvas: HTMLCanvasElement | string, options: CanvasRenderingContext2DSettings = {colorSpace: 'display-p3'}): this {
53
+ mount(canvas: HTMLCanvasElement | string, options: CanvasRenderingContext2DSettings = {colorSpace: 'display-p3'}, frameRate: number = 60): this {
54
54
  if (typeof canvas === 'string') {
55
55
  const el = document.querySelector<HTMLCanvasElement>(canvas);
56
56
 
@@ -61,7 +61,7 @@ export abstract class Effect<TConfig = Record<string, unknown>> implements Simul
61
61
  canvas = el;
62
62
  }
63
63
 
64
- this.#canvas = new SimulationCanvas(canvas, this as unknown as SimulationLayer, 60, options);
64
+ this.#canvas = new SimulationCanvas(canvas, this as unknown as SimulationLayer, frameRate, options);
65
65
  return this;
66
66
  }
67
67
 
package/src/scene.ts CHANGED
@@ -38,19 +38,22 @@ class SceneCanvas extends LimitedFrameRateCanvas {
38
38
  }
39
39
 
40
40
  draw(): void {
41
- this.canvas.height = this.height;
42
- this.canvas.width = this.width;
41
+ const dpr = this.dpr;
42
+ this.canvas.height = this.height * dpr;
43
+ this.canvas.width = this.width * dpr;
43
44
 
44
45
  const ctx = this.context;
46
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
45
47
  ctx.clearRect(0, 0, this.width, this.height);
46
48
 
47
49
  for (const layer of this.#layers) {
48
50
  if (layer.fade) {
49
- const offCtx = this.#getOffscreenCtx(this.width, this.height);
51
+ const offCtx = this.#getOffscreenCtx(this.width * dpr, this.height * dpr);
52
+ offCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
50
53
  offCtx.clearRect(0, 0, this.width, this.height);
51
54
  layer.draw(offCtx, this.width, this.height);
52
55
  applyEdgeFade(offCtx, this.width, this.height, layer.fade);
53
- ctx.drawImage(this.#offscreen!, 0, 0);
56
+ ctx.drawImage(this.#offscreen!, 0, 0, this.width, this.height);
54
57
  } else {
55
58
  ctx.save();
56
59
  layer.draw(ctx, this.width, this.height);
@@ -71,8 +74,8 @@ class SceneCanvas extends LimitedFrameRateCanvas {
71
74
  super.onResize();
72
75
 
73
76
  if (this.#offscreen) {
74
- this.#offscreen.width = this.width;
75
- this.#offscreen.height = this.height;
77
+ this.#offscreen.width = this.width * this.dpr;
78
+ this.#offscreen.height = this.height * this.dpr;
76
79
  }
77
80
 
78
81
  for (const layer of this.#layers) {
@@ -36,17 +36,20 @@ export class SimulationCanvas extends LimitedFrameRateCanvas {
36
36
  }
37
37
 
38
38
  draw(): void {
39
- this.canvas.height = this.height;
40
- this.canvas.width = this.width;
39
+ const dpr = this.dpr;
40
+ this.canvas.height = this.height * dpr;
41
+ this.canvas.width = this.width * dpr;
41
42
 
42
43
  const ctx = this.context;
44
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
43
45
 
44
46
  if (this.#simulation.fade) {
45
- const offCtx = this.#getOffscreenCtx(this.width, this.height);
47
+ const offCtx = this.#getOffscreenCtx(this.width * dpr, this.height * dpr);
48
+ offCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
46
49
  offCtx.clearRect(0, 0, this.width, this.height);
47
50
  this.#simulation.draw(offCtx, this.width, this.height);
48
51
  applyEdgeFade(offCtx, this.width, this.height, this.#simulation.fade);
49
- ctx.drawImage(this.#offscreen!, 0, 0);
52
+ ctx.drawImage(this.#offscreen!, 0, 0, this.width, this.height);
50
53
  } else {
51
54
  ctx.save();
52
55
  this.#simulation.draw(ctx, this.width, this.height);
@@ -63,8 +66,8 @@ export class SimulationCanvas extends LimitedFrameRateCanvas {
63
66
  super.onResize();
64
67
 
65
68
  if (this.#offscreen) {
66
- this.#offscreen.width = this.width;
67
- this.#offscreen.height = this.height;
69
+ this.#offscreen.width = this.width * this.dpr;
70
+ this.#offscreen.height = this.height * this.dpr;
68
71
  }
69
72
 
70
73
  this.#simulation.onResize(this.width, this.height);