juice.ts

examples/fxedit/juice.ts · raw · open the example · all examples · site home

Exports: Juice

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// Effects: bursts of sparks, confetti or ripples in the graph's world layer,
// shakes and toasts. CSS animations on throwaway elements (fxedit.css).
import type { Theme } from "./themes.js";

export class Juice {
  theme: Theme;
  // Off with prefers-reduced-motion
  enabled = !matchMedia("(prefers-reduced-motion: reduce)").matches;
  private layer: HTMLElement;
  private hud: HTMLElement;

  // `layer` is in world coordinates, `hud` in screen coordinates
  constructor(layer: HTMLElement, hud: HTMLElement, theme: Theme) {
    this.layer = layer;
    this.hud = hud;
    this.theme = theme;
  }

  burst(x: number, y: number, { color, count, kind = this.theme.burst }: { color?: string; count?: number; kind?: Theme["burst"] } = {}) {
    if (!this.enabled) {
      return;
    }
    const colors = color ? [color, ...this.theme.colors] : this.theme.colors;
    if (kind === "ripple") {
      for (let i = 0; i < (count ?? 2); i += 1) {
        this.spawn("ripple", x, y, { "--delay": `${i * 90}ms`, "--color": colors[i % colors.length] });
      }
      return;
    }
    const n = count ?? (kind === "confetti" ? 18 : 14);
    for (let i = 0; i < n; i += 1) {
      const angle = kind === "confetti" ? -Math.PI / 2 + (Math.random() - 0.5) * 2.2 : (i / n) * Math.PI * 2 + Math.random() * 0.4;
      const distance = kind === "confetti" ? 50 + Math.random() * 60 : 26 + Math.random() * 30;
      this.spawn(kind, x, y, {
        "--dx": `${Math.cos(angle) * distance}px`,
        "--dy": `${Math.sin(angle) * distance}px`,
        "--rot": `${Math.round((Math.random() - 0.5) * 900)}deg`,
        "--angle": `${angle}rad`,
        "--color": colors[i % colors.length],
        "--delay": `${Math.round(Math.random() * 60)}ms`,
      });
    }
  }

  private spawn(kind: string, x: number, y: number, vars: Record<string, string>) {
    const el = document.createElement("i");
    el.className = `fx-particle ${kind}`;
    el.style.left = `${x}px`;
    el.style.top = `${y}px`;
    for (const [name, value] of Object.entries(vars)) {
      el.style.setProperty(name, value);
    }
    el.addEventListener("animationend", () => el.remove(), { once: true });
    setTimeout(() => el.remove(), 2000);
    this.layer.append(el);
  }

  // Restartable one-shot animation class
  shake(el: Element, name = "fx-shake") {
    if (!this.enabled) {
      return;
    }
    el.classList.remove(name);
    void (el as HTMLElement).offsetWidth;
    el.classList.add(name);
    setTimeout(() => el.classList.remove(name), 700);
  }

  toast(text: string, kind: "info" | "error" | "ok" = "info") {
    const el = document.createElement("div");
    el.className = `fx-toast ${kind}`;
    el.textContent = text;
    this.hud.append(el);
    while (this.hud.children.length > 3) {
      this.hud.firstElementChild?.remove();
    }
    setTimeout(() => el.classList.add("leaving"), 2400);
    setTimeout(() => el.remove(), 2900);
  }
}