@prestyj/cli 5.28.1 → 5.29.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/assets/motion/bin/contact-sheet.mjs +9 -3
- package/assets/motion/bin/cues.mjs +337 -0
- package/assets/motion/bin/library.mjs +53 -10
- package/assets/motion/bin/motion-blur.mjs +943 -0
- package/assets/motion/bin/motion-check.mjs +354 -3
- package/assets/motion/bin/music-fit.mjs +436 -0
- package/assets/motion/bin/pdf-extract.mjs +22 -10
- package/assets/motion/bin/reference-study.mjs +346 -0
- package/assets/motion/bin/score-synth.mjs +1052 -93
- package/assets/motion/library/README.md +50 -14
- package/assets/motion/library/kit/moves.js +1981 -0
- package/assets/motion/library/library.json +232 -0
- package/assets/motion/library/pieces/camera-rig/meta.json +13 -0
- package/assets/motion/library/pieces/camera-rig/piece.html +153 -0
- package/assets/motion/library/pieces/camera-rig/preview.jpg +0 -0
- package/assets/motion/library/pieces/chain-knock/meta.json +13 -0
- package/assets/motion/library/pieces/chain-knock/piece.html +195 -0
- package/assets/motion/library/pieces/chain-knock/preview.jpg +0 -0
- package/assets/motion/library/pieces/gather-to-logo/meta.json +13 -0
- package/assets/motion/library/pieces/gather-to-logo/piece.html +159 -0
- package/assets/motion/library/pieces/gather-to-logo/preview.jpg +0 -0
- package/assets/motion/library/pieces/morph-carry/meta.json +13 -0
- package/assets/motion/library/pieces/morph-carry/piece.html +173 -0
- package/assets/motion/library/pieces/morph-carry/preview.jpg +0 -0
- package/assets/motion/library/pieces/one-shape-journey/meta.json +13 -0
- package/assets/motion/library/pieces/one-shape-journey/piece.html +195 -0
- package/assets/motion/library/pieces/one-shape-journey/preview.jpg +0 -0
- package/assets/motion/library/pieces/open-from-subject/meta.json +13 -0
- package/assets/motion/library/pieces/open-from-subject/piece.html +168 -0
- package/assets/motion/library/pieces/open-from-subject/preview.jpg +0 -0
- package/assets/motion/library/pieces/request-to-result/meta.json +13 -0
- package/assets/motion/library/pieces/request-to-result/piece.html +212 -0
- package/assets/motion/library/pieces/request-to-result/preview.jpg +0 -0
- package/assets/motion/library/pieces/scale-dive/meta.json +13 -0
- package/assets/motion/library/pieces/scale-dive/piece.html +321 -0
- package/assets/motion/library/pieces/scale-dive/preview.jpg +0 -0
- package/assets/motion/library/pieces/screen-replica-steps/meta.json +13 -0
- package/assets/motion/library/pieces/screen-replica-steps/piece.html +366 -0
- package/assets/motion/library/pieces/screen-replica-steps/preview.jpg +0 -0
- package/assets/motion/library/pieces/zoom-into-card/meta.json +13 -0
- package/assets/motion/library/pieces/zoom-into-card/piece.html +179 -0
- package/assets/motion/library/pieces/zoom-into-card/preview.jpg +0 -0
- package/assets/motion/library/sheets/diagram.jpg +0 -0
- package/assets/motion/library/sheets/frame.jpg +0 -0
- package/assets/motion/library/sheets/transition.jpg +0 -0
- package/assets/motion/library/sheets/ui.jpg +0 -0
- package/assets/motion/references/build-sheet.md +206 -0
- package/assets/motion/references/runtime/determinism-rules.md +1 -1
- package/assets/motion/references/runtime/gsap-easing-and-stagger.md +29 -29
- package/assets/motion/references/runtime/inputs-and-assets.md +7 -12
- package/assets/motion/references/runtime/lint-validate-inspect.md +3 -3
- package/assets/motion/references/runtime/minimal-composition.md +1 -1
- package/assets/motion/references/runtime/preview-render.md +3 -3
- package/assets/motion/skills/app-walkthrough/SKILL.md +66 -0
- package/assets/motion/skills/before-after/SKILL.md +53 -0
- package/assets/motion/skills/brand-kit/SKILL.md +3 -3
- package/assets/motion/skills/dev-tool-video/SKILL.md +57 -0
- package/assets/motion/skills/launch-video/SKILL.md +62 -0
- package/assets/motion/skills/match-reference/SKILL.md +58 -0
- package/assets/motion/skills/motion/SKILL.md +78 -85
- package/assets/motion/skills/source-ingest/SKILL.md +17 -6
- package/assets/motion/skills/website-video/SKILL.md +59 -0
- package/assets/skills/bulletproof/SKILL.md +36 -11
- package/assets/skills/bulletproof/references/agent-surface.md +19 -9
- package/assets/skills/bulletproof/references/audit-protocol.md +20 -5
- package/assets/skills/bulletproof/references/platform-playbooks.md +5 -4
- package/assets/skills/bulletproof/references/provenance.md +26 -1
- package/assets/skills/bulletproof/references/secure-defaults.md +6 -5
- package/assets/skills/bulletproof/references/supply-chain.md +21 -17
- package/assets/skills/bulletproof/references/threat-landscape.md +28 -26
- package/assets/skills/bulletproof/references/verification.md +2 -0
- package/assets/skills/clarify/SKILL.md +25 -16
- package/assets/skills/code-review/SKILL.md +71 -13
- package/assets/skills/code-review/references/agent-diffs.md +27 -0
- package/assets/skills/code-review/references/tests.md +19 -0
- package/assets/skills/compliance-guard/SKILL.md +20 -5
- package/assets/skills/compliance-guard/references/artifacts.md +1 -1
- package/assets/skills/compliance-guard/references/eu-uk.md +16 -16
- package/assets/skills/compliance-guard/references/lawsuit-vectors.md +5 -5
- package/assets/skills/compliance-guard/references/provenance.md +41 -2
- package/assets/skills/compliance-guard/references/sector-gates.md +3 -3
- package/assets/skills/compliance-guard/references/security-baseline.md +2 -2
- package/assets/skills/compliance-guard/references/trigger-map.md +5 -5
- package/assets/skills/compliance-guard/references/us.md +27 -21
- package/assets/skills/durable/SKILL.md +87 -79
- package/assets/skills/durable/references/agent-db-safety.md +69 -0
- package/assets/skills/durable/references/backups-and-runtime.md +19 -12
- package/assets/skills/durable/references/migrations-and-schema.md +13 -6
- package/assets/skills/evidence-led-ui/SKILL.md +69 -127
- package/assets/skills/evidence-led-ui/references/anti-defaults.md +107 -208
- package/assets/skills/evidence-led-ui/references/direction.md +124 -0
- package/assets/skills/evidence-led-ui/references/production-contract.md +8 -0
- package/assets/skills/evidence-led-ui/references/provenance.md +24 -1
- package/assets/skills/lean/SKILL.md +90 -71
- package/assets/skills/lean/references/memory-and-processes.md +3 -2
- package/assets/skills/lean/references/playbooks.md +37 -12
- package/assets/skills/refactoring/SKILL.md +24 -3
- package/assets/skills/refactoring/references/agent-pitfalls.md +4 -1
- package/assets/skills/refactoring/references/legacy.md +21 -0
- package/assets/skills/root-cause/SKILL.md +20 -10
- package/assets/skills/shared-language/SKILL.md +16 -14
- package/assets/skills/tdd/SKILL.md +27 -15
- package/dist/app-sidecar.js +203 -47
- package/dist/app-sidecar.js.map +1 -1
- package/dist/cli.js +17 -26
- package/dist/cli.js.map +1 -1
- package/dist/core/acceptance-checks.d.ts +48 -0
- package/dist/core/acceptance-checks.js +144 -0
- package/dist/core/acceptance-checks.js.map +1 -0
- package/dist/core/agent-session.d.ts +106 -72
- package/dist/core/agent-session.js +539 -399
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/agents.d.ts +6 -5
- package/dist/core/agents.js.map +1 -1
- package/dist/core/ask-user.d.ts +90 -8
- package/dist/core/ask-user.js +124 -13
- package/dist/core/ask-user.js.map +1 -1
- package/dist/core/bundled-agents.js +1 -3
- package/dist/core/bundled-agents.js.map +1 -1
- package/dist/core/cache-diagnostics.d.ts +68 -0
- package/dist/core/cache-diagnostics.js +196 -0
- package/dist/core/cache-diagnostics.js.map +1 -0
- package/dist/core/cache-expiry.d.ts +87 -0
- package/dist/core/cache-expiry.js +111 -0
- package/dist/core/cache-expiry.js.map +1 -0
- package/dist/core/compaction/compactor.js +78 -48
- package/dist/core/compaction/compactor.js.map +1 -1
- package/dist/core/compaction/plan-step-policy.d.ts +46 -0
- package/dist/core/compaction/plan-step-policy.js +57 -0
- package/dist/core/compaction/plan-step-policy.js.map +1 -0
- package/dist/core/destructive-git-guard.d.ts +90 -0
- package/dist/core/destructive-git-guard.js +871 -0
- package/dist/core/destructive-git-guard.js.map +1 -0
- package/dist/core/event-bus.d.ts +3 -0
- package/dist/core/event-bus.js +5 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/injection-detect.d.ts +38 -0
- package/dist/core/injection-detect.js +232 -0
- package/dist/core/injection-detect.js.map +1 -0
- package/dist/core/keep-awake.d.ts +88 -0
- package/dist/core/keep-awake.js +251 -0
- package/dist/core/keep-awake.js.map +1 -0
- package/dist/core/mcp/client.d.ts +72 -0
- package/dist/core/mcp/client.js +264 -41
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/content.js +6 -2
- package/dist/core/mcp/content.js.map +1 -1
- package/dist/core/mcp/store.d.ts +6 -1
- package/dist/core/mcp/store.js +12 -1
- package/dist/core/mcp/store.js.map +1 -1
- package/dist/core/mcp/types.d.ts +18 -0
- package/dist/core/model-unavailable.d.ts +14 -0
- package/dist/core/model-unavailable.js +23 -0
- package/dist/core/model-unavailable.js.map +1 -0
- package/dist/core/node-debugger.d.ts +148 -0
- package/dist/core/node-debugger.js +642 -0
- package/dist/core/node-debugger.js.map +1 -0
- package/dist/core/package-threats.d.ts +18 -0
- package/dist/core/package-threats.js +168 -0
- package/dist/core/package-threats.js.map +1 -0
- package/dist/core/persistent-shell.d.ts +58 -6
- package/dist/core/persistent-shell.js +331 -49
- package/dist/core/persistent-shell.js.map +1 -1
- package/dist/core/process-manager.d.ts +14 -0
- package/dist/core/process-manager.js +61 -0
- package/dist/core/process-manager.js.map +1 -1
- package/dist/core/progress/git-xp.js +8 -14
- package/dist/core/progress/git-xp.js.map +1 -1
- package/dist/core/session-history.d.ts +12 -0
- package/dist/core/session-history.js +27 -0
- package/dist/core/session-history.js.map +1 -1
- package/dist/core/session-manager.d.ts +13 -1
- package/dist/core/session-manager.js +38 -18
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/session-summary-index.d.ts +37 -0
- package/dist/core/session-summary-index.js +172 -0
- package/dist/core/session-summary-index.js.map +1 -0
- package/dist/core/settings-manager.d.ts +2 -0
- package/dist/core/settings-manager.js +10 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/shell-threats-popular-packages.d.ts +11 -0
- package/dist/core/shell-threats-popular-packages.js +675 -0
- package/dist/core/shell-threats-popular-packages.js.map +1 -0
- package/dist/core/shell-threats.d.ts +8 -0
- package/dist/core/shell-threats.js +186 -0
- package/dist/core/shell-threats.js.map +1 -0
- package/dist/core/skills.js +3 -1
- package/dist/core/skills.js.map +1 -1
- package/dist/core/stream-rules.d.ts +30 -0
- package/dist/core/stream-rules.js +151 -0
- package/dist/core/stream-rules.js.map +1 -0
- package/dist/core/subagent-manager.d.ts +20 -5
- package/dist/core/subagent-manager.js +22 -8
- package/dist/core/subagent-manager.js.map +1 -1
- package/dist/core/subagent-receipt.d.ts +54 -0
- package/dist/core/subagent-receipt.js +276 -0
- package/dist/core/subagent-receipt.js.map +1 -0
- package/dist/core/subagent-turn-record.d.ts +2 -0
- package/dist/core/subagent-turn-record.js.map +1 -1
- package/dist/core/test-impact.d.ts +73 -0
- package/dist/core/test-impact.js +467 -0
- package/dist/core/test-impact.js.map +1 -0
- package/dist/core/thinking-level.d.ts +1 -1
- package/dist/core/thinking-level.js +1 -1
- package/dist/core/thinking-level.js.map +1 -1
- package/dist/core/verification-gate.d.ts +2 -0
- package/dist/core/verification-gate.js +4 -0
- package/dist/core/verification-gate.js.map +1 -1
- package/dist/core/verification-snapshot.js +3 -5
- package/dist/core/verification-snapshot.js.map +1 -1
- package/dist/core/workspace-guard.d.ts +18 -7
- package/dist/core/workspace-guard.js +227 -60
- package/dist/core/workspace-guard.js.map +1 -1
- package/dist/interactive.js +2 -1
- package/dist/interactive.js.map +1 -1
- package/dist/modes/subagent-worker-mode.js +34 -6
- package/dist/modes/subagent-worker-mode.js.map +1 -1
- package/dist/motion-agent/motion-agent.d.ts +6 -2
- package/dist/motion-agent/motion-agent.js +5 -7
- package/dist/motion-agent/motion-agent.js.map +1 -1
- package/dist/motion-agent/motion-prompt.d.ts +1 -1
- package/dist/motion-agent/motion-prompt.js +14 -19
- package/dist/motion-agent/motion-prompt.js.map +1 -1
- package/dist/motion-agent/motion-review.d.ts +10 -3
- package/dist/motion-agent/motion-review.js +15 -7
- package/dist/motion-agent/motion-review.js.map +1 -1
- package/dist/motion-agent/motion-studio-context.js +1 -1
- package/dist/motion-agent/motion-studio-context.js.map +1 -1
- package/dist/system-prompt.js +3 -1
- package/dist/system-prompt.js.map +1 -1
- package/dist/test-support/keep-alive.d.ts +14 -0
- package/dist/test-support/keep-alive.js +17 -0
- package/dist/test-support/keep-alive.js.map +1 -0
- package/dist/tools/ask-user.js +3 -3
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/bash-read-evidence.d.ts +10 -0
- package/dist/tools/bash-read-evidence.js +133 -0
- package/dist/tools/bash-read-evidence.js.map +1 -0
- package/dist/tools/bash.d.ts +10 -1
- package/dist/tools/bash.js +115 -7
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/debug.d.ts +54 -0
- package/dist/tools/debug.js +233 -0
- package/dist/tools/debug.js.map +1 -0
- package/dist/tools/edit.js +12 -4
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/goals.d.ts +1 -1
- package/dist/tools/index.d.ts +19 -2
- package/dist/tools/index.js +47 -7
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/prompt-hints.js +2 -0
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/read-tracker.d.ts +5 -0
- package/dist/tools/read-tracker.js +19 -9
- package/dist/tools/read-tracker.js.map +1 -1
- package/dist/tools/read.js +3 -2
- package/dist/tools/read.js.map +1 -1
- package/dist/tools/skill.js +5 -0
- package/dist/tools/skill.js.map +1 -1
- package/dist/tools/subagent-control.js +44 -8
- package/dist/tools/subagent-control.js.map +1 -1
- package/dist/tools/subagent-shared.d.ts +29 -8
- package/dist/tools/subagent-shared.js +45 -14
- package/dist/tools/subagent-shared.js.map +1 -1
- package/dist/tools/subagent.d.ts +8 -2
- package/dist/tools/subagent.js +28 -10
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/task-output.js +3 -2
- package/dist/tools/task-output.js.map +1 -1
- package/dist/tools/task-send.d.ts +1 -1
- package/dist/tools/task-send.js +15 -1
- package/dist/tools/task-send.js.map +1 -1
- package/dist/tools/tool-tiers.d.ts +2 -2
- package/dist/tools/tool-tiers.js +3 -2
- package/dist/tools/tool-tiers.js.map +1 -1
- package/dist/tools/truncate.d.ts +21 -0
- package/dist/tools/truncate.js +187 -0
- package/dist/tools/truncate.js.map +1 -1
- package/dist/tools/ui-adopt.js +2 -0
- package/dist/tools/ui-adopt.js.map +1 -1
- package/dist/ui/App.d.ts +0 -4
- package/dist/ui/App.js +5 -28
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/ActivityIndicator.js +1 -0
- package/dist/ui/components/ActivityIndicator.js.map +1 -1
- package/dist/ui/hooks/useAgentLoop.d.ts +1 -8
- package/dist/ui/hooks/useAgentLoop.js +1 -119
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/dist/ui/render.d.ts +0 -4
- package/dist/ui/render.js +0 -2
- package/dist/ui/render.js.map +1 -1
- package/dist/utils/git.d.ts +77 -0
- package/dist/utils/git.js +285 -21
- package/dist/utils/git.js.map +1 -1
- package/dist/utils/github-ci.js +2 -1
- package/dist/utils/github-ci.js.map +1 -1
- package/dist/utils/github.js +11 -9
- package/dist/utils/github.js.map +1 -1
- package/dist/utils/image.d.ts +14 -0
- package/dist/utils/image.js +16 -0
- package/dist/utils/image.js.map +1 -1
- package/dist/utils/process.d.ts +20 -0
- package/dist/utils/process.js +98 -0
- package/dist/utils/process.js.map +1 -1
- package/package.json +5 -5
- package/assets/motion/references/motion-language.md +0 -128
- package/assets/motion/skills/video-qa/SKILL.md +0 -89
- package/dist/core/ideal-review-subagent.d.ts +0 -56
- package/dist/core/ideal-review-subagent.js +0 -112
- package/dist/core/ideal-review-subagent.js.map +0 -1
- package/dist/core/ideal-review.d.ts +0 -82
- package/dist/core/ideal-review.js +0 -242
- package/dist/core/ideal-review.js.map +0 -1
- package/dist/motion-agent/motion-check-tool.d.ts +0 -35
- package/dist/motion-agent/motion-check-tool.js +0 -514
- package/dist/motion-agent/motion-check-tool.js.map +0 -1
|
@@ -0,0 +1,1981 @@
|
|
|
1
|
+
// EZ Motion move kit: springs, arrivals, handoffs, contact, an operated
|
|
2
|
+
// camera and a pressing cursor for HyperFrames compositions.
|
|
3
|
+
//
|
|
4
|
+
// Install with `node library.mjs kit <project>` and put the printed <script>
|
|
5
|
+
// in the root index.html <head>, after GSAP. Then, in any composition script:
|
|
6
|
+
//
|
|
7
|
+
// const kit = window.GGMotionKit;
|
|
8
|
+
// const tl = gsap.timeline({ paused: true });
|
|
9
|
+
// const landed = kit.dropLetters(tl, root.querySelector(".name"), 0.4);
|
|
10
|
+
// kit.camera(tl, root.querySelector(".world"), [...keys], { duration: 8 });
|
|
11
|
+
//
|
|
12
|
+
// Every move adds tweens to the timeline you pass and returns the time of its
|
|
13
|
+
// key moment (landing, press, arrival) so the next move can start from it.
|
|
14
|
+
// Everything is a pure function of timeline time: no randomness except the
|
|
15
|
+
// seeded generator, no clocks, no free-running loops. Seeking any frame twice
|
|
16
|
+
// draws the same picture.
|
|
17
|
+
//
|
|
18
|
+
// Coordinates: positions are CSS pixels in the space of the element's offset
|
|
19
|
+
// parent (the composition's 1920x1080 stage unless you nest). `kit.centerOf`
|
|
20
|
+
// reads layout offsets, so camera and transform moves never disturb it.
|
|
21
|
+
(function (root) {
|
|
22
|
+
"use strict";
|
|
23
|
+
|
|
24
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
25
|
+
const lerp = (a, b, p) => a + (b - a) * p;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------- timing
|
|
28
|
+
|
|
29
|
+
/** Named spring settings. Stiffness, damping and mass, as in a physical spring. */
|
|
30
|
+
const springs = {
|
|
31
|
+
snappy: { stiffness: 380, damping: 30, mass: 1 },
|
|
32
|
+
bouncy: { stiffness: 240, damping: 13, mass: 1 },
|
|
33
|
+
soft: { stiffness: 110, damping: 19, mass: 1 },
|
|
34
|
+
heavy: { stiffness: 140, damping: 22, mass: 2.2 },
|
|
35
|
+
whip: { stiffness: 300, damping: 22, mass: 1 },
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function springParams(spec) {
|
|
39
|
+
const s = typeof spec === "string" ? springs[spec] : spec;
|
|
40
|
+
if (!s) throw new Error(`Unknown spring "${spec}"`);
|
|
41
|
+
const stiffness = s.stiffness ?? 170;
|
|
42
|
+
const damping = s.damping ?? 16;
|
|
43
|
+
const mass = s.mass ?? 1;
|
|
44
|
+
const omega = Math.sqrt(stiffness / mass);
|
|
45
|
+
const zeta = damping / (2 * Math.sqrt(stiffness * mass));
|
|
46
|
+
return { omega, zeta, velocity: s.velocity ?? 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Position of a damped spring released at 0 heading for 1, after t seconds.
|
|
51
|
+
* Closed form, so any time can be sampled directly.
|
|
52
|
+
*/
|
|
53
|
+
function spring(t, spec = "bouncy") {
|
|
54
|
+
if (t <= 0) return 0;
|
|
55
|
+
const { omega, zeta, velocity: v0 } = springParams(spec);
|
|
56
|
+
if (zeta < 1) {
|
|
57
|
+
const wd = omega * Math.sqrt(1 - zeta * zeta);
|
|
58
|
+
const decay = Math.exp(-zeta * omega * t);
|
|
59
|
+
const b = (zeta * omega - v0) / wd;
|
|
60
|
+
return 1 - decay * (Math.cos(wd * t) + b * Math.sin(wd * t));
|
|
61
|
+
}
|
|
62
|
+
if (zeta === 1) {
|
|
63
|
+
return 1 - Math.exp(-omega * t) * (1 + (omega - v0) * t);
|
|
64
|
+
}
|
|
65
|
+
const root1 = -omega * (zeta - Math.sqrt(zeta * zeta - 1));
|
|
66
|
+
const root2 = -omega * (zeta + Math.sqrt(zeta * zeta - 1));
|
|
67
|
+
const a = (v0 + root2) / (root1 - root2);
|
|
68
|
+
return 1 + a * Math.exp(root1 * t) + (-1 - a) * Math.exp(root2 * t);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Seconds until the spring stays within `tolerance` of its target for good. */
|
|
72
|
+
function settleTime(spec = "bouncy", tolerance = 0.002) {
|
|
73
|
+
const { omega, zeta, velocity: v0 } = springParams(spec);
|
|
74
|
+
if (zeta < 1) {
|
|
75
|
+
// The motion never leaves its decaying envelope, so the bound is exact enough.
|
|
76
|
+
const wd = omega * Math.sqrt(1 - zeta * zeta);
|
|
77
|
+
const b = (zeta * omega - v0) / wd;
|
|
78
|
+
const amplitude = Math.sqrt(1 + b * b);
|
|
79
|
+
return Math.max(0.05, Math.log(amplitude / tolerance) / (zeta * omega));
|
|
80
|
+
}
|
|
81
|
+
const step = 1 / 600;
|
|
82
|
+
for (let t = step; t < 30; t += step) {
|
|
83
|
+
if (Math.abs(1 - spring(t, spec)) < tolerance) return t;
|
|
84
|
+
}
|
|
85
|
+
return 30;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A GSAP-compatible ease that follows the spring over its settle time. */
|
|
89
|
+
function springEase(spec = "bouncy") {
|
|
90
|
+
const settle = settleTime(spec);
|
|
91
|
+
return (p) => (p <= 0 ? 0 : p >= 1 ? 1 : spring(p * settle, spec));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Decaying vibration after a contact: 0 before it, then rings down. */
|
|
95
|
+
function ring(t, opts = {}) {
|
|
96
|
+
if (t < 0) return 0;
|
|
97
|
+
const freq = opts.freq ?? 11;
|
|
98
|
+
const decay = opts.decay ?? 7;
|
|
99
|
+
const amp = opts.amp ?? 1;
|
|
100
|
+
return amp * Math.exp(-decay * t) * Math.sin(2 * Math.PI * freq * t);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Curves, chosen by feel. All map 0..1 to 0..1 (springs overshoot first). */
|
|
104
|
+
const ease = {
|
|
105
|
+
linear: (p) => p,
|
|
106
|
+
// Sharp start, long glide into place: arrivals and reveals.
|
|
107
|
+
snap: (p) => 1 - (1 - p) ** 4,
|
|
108
|
+
// Starts slow, ends hard: things falling or diving into contact.
|
|
109
|
+
drop: (p) => p ** 2.4,
|
|
110
|
+
// Slow, even turn of the head: drifts and handheld pushes.
|
|
111
|
+
glide: (p) => 0.5 - 0.5 * Math.cos(Math.PI * p),
|
|
112
|
+
// Fast middle, soft ends: a hand moving a cursor or a camera across.
|
|
113
|
+
travel: (p) => (p < 0.5 ? 4 * p ** 3 : 1 - (-2 * p + 2) ** 3 / 2),
|
|
114
|
+
// Quick move that overshoots a little and settles.
|
|
115
|
+
whip: springEase("whip"),
|
|
116
|
+
settle: springEase("soft"),
|
|
117
|
+
bounce: springEase("bouncy"),
|
|
118
|
+
firm: springEase("snappy"),
|
|
119
|
+
// Holds still, then jumps.
|
|
120
|
+
cut: (p) => (p > 0 ? 1 : 0),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function easeOf(name, fallback) {
|
|
124
|
+
if (typeof name === "function") return name;
|
|
125
|
+
const fn = ease[name ?? fallback];
|
|
126
|
+
if (!fn) throw new Error(`Unknown ease "${name}"`);
|
|
127
|
+
return fn;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Seeded generator: same seed, same sequence. Returns numbers in [0, 1). */
|
|
131
|
+
function rng(seed = 1) {
|
|
132
|
+
let s = seed >>> 0 || 0x9e3779b9;
|
|
133
|
+
return () => {
|
|
134
|
+
s ^= s << 13;
|
|
135
|
+
s >>>= 0;
|
|
136
|
+
s ^= s >>> 17;
|
|
137
|
+
s ^= s << 5;
|
|
138
|
+
s >>>= 0;
|
|
139
|
+
return s / 4294967296;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hash(i, seed) {
|
|
144
|
+
let h = Math.imul(i | 0, 0x27d4eb2d) ^ Math.imul(seed | 0, 0x165667b1);
|
|
145
|
+
h = Math.imul(h ^ (h >>> 15), 0x85ebca77);
|
|
146
|
+
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae3d);
|
|
147
|
+
h ^= h >>> 16;
|
|
148
|
+
return (h >>> 0) / 4294967295;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Smooth deterministic noise in [-1, 1]; one wobble per unit of t. */
|
|
152
|
+
function noise(seed, t) {
|
|
153
|
+
const i = Math.floor(t);
|
|
154
|
+
const f = t - i;
|
|
155
|
+
const s = f * f * f * (f * (f * 6 - 15) + 10);
|
|
156
|
+
return lerp(hash(i, seed), hash(i + 1, seed), s) * 2 - 1;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ------------------------------------------------------------ elements
|
|
160
|
+
|
|
161
|
+
/** Centre of an element in `space`'s coordinates, from layout offsets. */
|
|
162
|
+
function centerOf(el, space) {
|
|
163
|
+
let x = el.offsetWidth / 2;
|
|
164
|
+
let y = el.offsetHeight / 2;
|
|
165
|
+
let node = el;
|
|
166
|
+
while (node && node !== space) {
|
|
167
|
+
x += node.offsetLeft;
|
|
168
|
+
y += node.offsetTop;
|
|
169
|
+
node = node.offsetParent;
|
|
170
|
+
}
|
|
171
|
+
return { x, y };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Box of an element in `space`'s coordinates: { x, y, w, h } (top-left). */
|
|
175
|
+
function rectOf(el, space) {
|
|
176
|
+
const c = centerOf(el, space);
|
|
177
|
+
return {
|
|
178
|
+
x: c.x - el.offsetWidth / 2,
|
|
179
|
+
y: c.y - el.offsetHeight / 2,
|
|
180
|
+
w: el.offsetWidth,
|
|
181
|
+
h: el.offsetHeight,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wrap an element's text in spans. "words" gives one span per word; "chars"
|
|
187
|
+
* gives one per character, grouped so words never break. `mask: true` puts
|
|
188
|
+
* each word in a clipping box for rise-from-below reveals.
|
|
189
|
+
*/
|
|
190
|
+
function split(el, by = "words", opts = {}) {
|
|
191
|
+
const doc = el.ownerDocument;
|
|
192
|
+
const text = el.textContent.trim().replace(/\s+/g, " ");
|
|
193
|
+
el.textContent = "";
|
|
194
|
+
const parts = [];
|
|
195
|
+
text.split(" ").forEach((word, wi) => {
|
|
196
|
+
if (wi > 0) el.appendChild(doc.createTextNode(" "));
|
|
197
|
+
const outer = doc.createElement("span");
|
|
198
|
+
outer.style.display = "inline-block";
|
|
199
|
+
outer.style.whiteSpace = "nowrap";
|
|
200
|
+
if (opts.mask) {
|
|
201
|
+
outer.style.overflow = "hidden";
|
|
202
|
+
outer.style.verticalAlign = "top";
|
|
203
|
+
outer.style.paddingBottom = "0.14em";
|
|
204
|
+
outer.style.marginBottom = "-0.14em";
|
|
205
|
+
}
|
|
206
|
+
el.appendChild(outer);
|
|
207
|
+
if (by === "words") {
|
|
208
|
+
const inner = doc.createElement("span");
|
|
209
|
+
inner.style.display = "inline-block";
|
|
210
|
+
inner.textContent = word;
|
|
211
|
+
outer.appendChild(inner);
|
|
212
|
+
parts.push(inner);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const ch of word) {
|
|
216
|
+
const span = doc.createElement("span");
|
|
217
|
+
span.style.display = "inline-block";
|
|
218
|
+
span.textContent = ch;
|
|
219
|
+
outer.appendChild(span);
|
|
220
|
+
parts.push(span);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return parts;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* One tween from `at` for `duration` that calls draw(localTime) on every
|
|
228
|
+
* render, including seeks. Draws the first frame immediately.
|
|
229
|
+
*/
|
|
230
|
+
function drive(tl, at, duration, draw) {
|
|
231
|
+
const state = { t: 0 };
|
|
232
|
+
draw(0);
|
|
233
|
+
tl.fromTo(
|
|
234
|
+
state,
|
|
235
|
+
{ t: 0 },
|
|
236
|
+
{
|
|
237
|
+
t: duration,
|
|
238
|
+
duration,
|
|
239
|
+
ease: "none",
|
|
240
|
+
immediateRender: false,
|
|
241
|
+
onUpdate: () => draw(state.t),
|
|
242
|
+
},
|
|
243
|
+
at,
|
|
244
|
+
);
|
|
245
|
+
return at + duration;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ------------------------------------------------------------ arrivals
|
|
249
|
+
|
|
250
|
+
/** Words rise into place from below their own line, one after another. */
|
|
251
|
+
function liftWords(tl, el, at, opts = {}) {
|
|
252
|
+
const words = split(el, "words", { mask: true });
|
|
253
|
+
const stagger = opts.stagger ?? 0.07;
|
|
254
|
+
const spec = opts.spring ?? "snappy";
|
|
255
|
+
const duration = opts.duration ?? settleTime(spec);
|
|
256
|
+
tl.fromTo(
|
|
257
|
+
words,
|
|
258
|
+
{ yPercent: opts.from ?? 115 },
|
|
259
|
+
{ yPercent: 0, duration, ease: springEase(spec), stagger },
|
|
260
|
+
at,
|
|
261
|
+
);
|
|
262
|
+
return at + stagger * Math.max(0, words.length - 1) + duration * 0.4;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Letters fall in, squash as they land and spring back up. Returns the last landing. */
|
|
266
|
+
function dropLetters(tl, el, at, opts = {}) {
|
|
267
|
+
const chars = split(el, "chars");
|
|
268
|
+
const height = opts.height ?? 260;
|
|
269
|
+
const fall = opts.fall ?? 0.38;
|
|
270
|
+
const stagger = opts.stagger ?? 0.05;
|
|
271
|
+
const squash = opts.squash ?? 0.24;
|
|
272
|
+
const recover = opts.spring ?? "bouncy";
|
|
273
|
+
let landed = at;
|
|
274
|
+
chars.forEach((ch, i) => {
|
|
275
|
+
const start = at + i * stagger;
|
|
276
|
+
landed = start + fall;
|
|
277
|
+
ch.style.transformOrigin = "50% 100%";
|
|
278
|
+
tl.fromTo(ch, { y: -height }, { y: 0, duration: fall, ease: ease.drop }, start);
|
|
279
|
+
tl.fromTo(
|
|
280
|
+
ch,
|
|
281
|
+
{ opacity: 0 },
|
|
282
|
+
{ opacity: 1, duration: Math.min(0.12, fall), ease: "none" },
|
|
283
|
+
start,
|
|
284
|
+
);
|
|
285
|
+
tl.to(
|
|
286
|
+
ch,
|
|
287
|
+
{ scaleY: 1 - squash, scaleX: 1 + squash * 0.55, duration: 0.06, ease: "power1.out" },
|
|
288
|
+
landed,
|
|
289
|
+
);
|
|
290
|
+
tl.to(
|
|
291
|
+
ch,
|
|
292
|
+
{ scaleY: 1, scaleX: 1, duration: settleTime(recover), ease: springEase(recover) },
|
|
293
|
+
landed + 0.06,
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
return landed;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** A clip-path reveal from one side. */
|
|
300
|
+
function reveal(tl, el, at, opts = {}) {
|
|
301
|
+
const from = opts.from ?? "bottom";
|
|
302
|
+
const hidden = {
|
|
303
|
+
top: "inset(0% 0% 100% 0%)",
|
|
304
|
+
bottom: "inset(100% 0% 0% 0%)",
|
|
305
|
+
left: "inset(0% 100% 0% 0%)",
|
|
306
|
+
right: "inset(0% 0% 0% 100%)",
|
|
307
|
+
}[from];
|
|
308
|
+
if (!hidden) throw new Error(`reveal: unknown side "${from}"`);
|
|
309
|
+
const duration = opts.duration ?? 0.8;
|
|
310
|
+
tl.fromTo(
|
|
311
|
+
el,
|
|
312
|
+
{ clipPath: hidden },
|
|
313
|
+
{ clipPath: "inset(0% 0% 0% 0%)", duration, ease: easeOf(opts.ease, "snap") },
|
|
314
|
+
at,
|
|
315
|
+
);
|
|
316
|
+
return at + duration;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Keystroke times for typing `text`: uneven like a person, with a beat
|
|
321
|
+
* after spaces and punctuation. Pure; used by typewrite.
|
|
322
|
+
*/
|
|
323
|
+
function typePlan(text, opts = {}) {
|
|
324
|
+
const cps = opts.cps ?? 22;
|
|
325
|
+
const jitter = opts.jitter ?? 0.4;
|
|
326
|
+
const next = rng(opts.seed ?? 11);
|
|
327
|
+
const times = [];
|
|
328
|
+
let t = 0;
|
|
329
|
+
for (let i = 0; i < text.length; i++) {
|
|
330
|
+
times.push(t);
|
|
331
|
+
let gap = (1 / cps) * (1 + jitter * (next() * 2 - 1));
|
|
332
|
+
if (/[\s]/.test(text[i])) gap *= 1.6;
|
|
333
|
+
if (/[.,;:!?]/.test(text[i])) gap *= 2.6;
|
|
334
|
+
t += gap;
|
|
335
|
+
}
|
|
336
|
+
return { times, duration: t };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Types the element's text (plain text only) with a caret while typing. */
|
|
340
|
+
function typewrite(tl, el, at, opts = {}) {
|
|
341
|
+
const text = opts.text ?? el.textContent;
|
|
342
|
+
const plan = typePlan(text, opts);
|
|
343
|
+
const caret = opts.caret === false ? "" : (opts.caret ?? "\u258d");
|
|
344
|
+
const hold = opts.caretHold ?? 0;
|
|
345
|
+
const draw = (t) => {
|
|
346
|
+
let n = 0;
|
|
347
|
+
while (n < plan.times.length && plan.times[n] <= t) n++;
|
|
348
|
+
const typing = t > 0 && n < text.length;
|
|
349
|
+
const blinking =
|
|
350
|
+
n === text.length &&
|
|
351
|
+
t - plan.duration < hold &&
|
|
352
|
+
Math.floor((t - plan.duration) * 2.2) % 2 === 0;
|
|
353
|
+
el.textContent = text.slice(0, n) + (caret && (typing || blinking) ? caret : "");
|
|
354
|
+
};
|
|
355
|
+
return drive(tl, at, plan.duration + hold, draw) - hold;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Draws an SVG stroke (a checkmark path, an underline) from start to end. */
|
|
359
|
+
function tick(tl, path, at, opts = {}) {
|
|
360
|
+
const length = path.getTotalLength();
|
|
361
|
+
const duration = opts.duration ?? 0.34;
|
|
362
|
+
tl.fromTo(
|
|
363
|
+
path,
|
|
364
|
+
{ strokeDasharray: length, strokeDashoffset: length },
|
|
365
|
+
{ strokeDashoffset: 0, duration, ease: easeOf(opts.ease, "snap") },
|
|
366
|
+
at,
|
|
367
|
+
);
|
|
368
|
+
// A round line cap still paints a dot at full offset: hide it until the draw.
|
|
369
|
+
tl.fromTo(path, { opacity: 0 }, { opacity: 1, duration: 0.01, ease: "none" }, at);
|
|
370
|
+
return at + duration;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Pops in from small with a spring overshoot. */
|
|
374
|
+
function pop(tl, el, at, opts = {}) {
|
|
375
|
+
const spec = opts.spring ?? "bouncy";
|
|
376
|
+
const duration = settleTime(spec);
|
|
377
|
+
tl.fromTo(el, { scale: opts.from ?? 0.3 }, { scale: 1, duration, ease: springEase(spec) }, at);
|
|
378
|
+
tl.fromTo(el, { opacity: 0 }, { opacity: 1, duration: 0.1, ease: "none" }, at);
|
|
379
|
+
return at + duration * 0.35;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Slams down from large, squashes on contact and settles. Returns the contact time. */
|
|
383
|
+
function stamp(tl, el, at, opts = {}) {
|
|
384
|
+
const fall = opts.duration ?? 0.2;
|
|
385
|
+
const rotate = opts.rotate ?? -4;
|
|
386
|
+
const contact = at + fall;
|
|
387
|
+
tl.fromTo(
|
|
388
|
+
el,
|
|
389
|
+
{ scale: opts.from ?? 2.4, rotation: rotate * 2.5, opacity: 0 },
|
|
390
|
+
{ scale: 1, rotation: rotate, opacity: 1, duration: fall, ease: ease.drop },
|
|
391
|
+
at,
|
|
392
|
+
);
|
|
393
|
+
tl.to(el, { scale: 0.93, duration: 0.05, ease: "power1.out" }, contact);
|
|
394
|
+
tl.to(
|
|
395
|
+
el,
|
|
396
|
+
{ scale: 1, duration: settleTime("bouncy"), ease: springEase("bouncy") },
|
|
397
|
+
contact + 0.05,
|
|
398
|
+
);
|
|
399
|
+
return contact;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ------------------------------------------------------------ handoffs
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A box becomes the next frame: morphs an absolutely positioned element's
|
|
406
|
+
* box (left, top, width, height, corner radius) into `to`, a { x, y, w, h,
|
|
407
|
+
* r } rect or an element measured in the same space. Defaults to the
|
|
408
|
+
* `travel` curve over 0.85 s so the change reads; pass `spring` for a
|
|
409
|
+
* small box that should land with weight.
|
|
410
|
+
*/
|
|
411
|
+
function reshape(tl, el, at, to, opts = {}) {
|
|
412
|
+
const target = typeof to.offsetWidth === "number" ? rectOf(to, el.offsetParent) : to;
|
|
413
|
+
const duration = opts.duration ?? (opts.spring ? settleTime(opts.spring) : 0.85);
|
|
414
|
+
const vars = {
|
|
415
|
+
left: target.x,
|
|
416
|
+
top: target.y,
|
|
417
|
+
width: target.w,
|
|
418
|
+
height: target.h,
|
|
419
|
+
duration,
|
|
420
|
+
ease: opts.spring && !opts.ease ? springEase(opts.spring) : easeOf(opts.ease, "travel"),
|
|
421
|
+
};
|
|
422
|
+
if (target.r !== undefined) {
|
|
423
|
+
// A pill radius (999px) is clamped by the browser and would stay round
|
|
424
|
+
// until the last moment: start from the radius actually drawn.
|
|
425
|
+
const view = el.ownerDocument && el.ownerDocument.defaultView;
|
|
426
|
+
const drawn = view ? parseFloat(view.getComputedStyle(el).borderTopLeftRadius) || 0 : 0;
|
|
427
|
+
const cap = Math.min(el.offsetWidth, el.offsetHeight) / 2;
|
|
428
|
+
if (drawn > cap) tl.set(el, { borderRadius: cap }, at);
|
|
429
|
+
vars.borderRadius = target.r;
|
|
430
|
+
}
|
|
431
|
+
tl.to(el, vars, at);
|
|
432
|
+
return at + duration * 0.45;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Radius that covers the whole frame from (x, y). */
|
|
436
|
+
function coverRadius(x, y, width, height) {
|
|
437
|
+
return (
|
|
438
|
+
Math.max(
|
|
439
|
+
Math.hypot(x, y),
|
|
440
|
+
Math.hypot(width - x, y),
|
|
441
|
+
Math.hypot(x, height - y),
|
|
442
|
+
Math.hypot(width - x, height - y),
|
|
443
|
+
) + 2
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The next stage opens as a growing circle from a point on the subject. */
|
|
448
|
+
function openFrom(tl, stage, at, opts = {}) {
|
|
449
|
+
const width = opts.width ?? 1920;
|
|
450
|
+
const height = opts.height ?? 1080;
|
|
451
|
+
const x = opts.x ?? width / 2;
|
|
452
|
+
const y = opts.y ?? height / 2;
|
|
453
|
+
const duration = opts.duration ?? 0.9;
|
|
454
|
+
const end = coverRadius(x, y, width, height);
|
|
455
|
+
tl.fromTo(
|
|
456
|
+
stage,
|
|
457
|
+
{ clipPath: `circle(${opts.from ?? 0}px at ${x}px ${y}px)` },
|
|
458
|
+
{
|
|
459
|
+
clipPath: `circle(${end}px at ${x}px ${y}px)`,
|
|
460
|
+
duration,
|
|
461
|
+
ease: easeOf(opts.ease, "travel"),
|
|
462
|
+
},
|
|
463
|
+
at,
|
|
464
|
+
);
|
|
465
|
+
return at + duration;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* The camera pushes through a point (a card, a letter, a hole) until it
|
|
470
|
+
* fills the frame, then hands over to `next`. Give the scene its own layer:
|
|
471
|
+
* this sets transform-origin and scale on `layer`.
|
|
472
|
+
*/
|
|
473
|
+
function diveThrough(tl, layer, at, opts = {}) {
|
|
474
|
+
const duration = opts.duration ?? 1.0;
|
|
475
|
+
const zoom = opts.zoom ?? 16;
|
|
476
|
+
tl.set(layer, { transformOrigin: `${opts.x ?? 960}px ${opts.y ?? 540}px` }, at);
|
|
477
|
+
tl.fromTo(layer, { scale: 1 }, { scale: zoom, duration, ease: easeOf(opts.ease, "drop") }, at);
|
|
478
|
+
const end = at + duration;
|
|
479
|
+
tl.set(layer, { visibility: "hidden" }, end);
|
|
480
|
+
if (opts.next) {
|
|
481
|
+
tl.set(opts.next, { visibility: "hidden" }, 0);
|
|
482
|
+
tl.set(opts.next, { visibility: "visible" }, end);
|
|
483
|
+
}
|
|
484
|
+
return end;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Point on a quadratic curve from a to b, bowed sideways by `bow` × distance. */
|
|
488
|
+
function arcPoint(a, b, bow, p) {
|
|
489
|
+
const mx = (a.x + b.x) / 2;
|
|
490
|
+
const my = (a.y + b.y) / 2;
|
|
491
|
+
const dx = b.x - a.x;
|
|
492
|
+
const dy = b.y - a.y;
|
|
493
|
+
const cx = mx - dy * bow;
|
|
494
|
+
const cy = my + dx * bow;
|
|
495
|
+
const u = 1 - p;
|
|
496
|
+
return {
|
|
497
|
+
x: u * u * a.x + 2 * u * p * cx + p * p * b.x,
|
|
498
|
+
y: u * u * a.y + 2 * u * p * cy + p * p * b.y,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Scattered items travel on arcs into one point (the logo, the end card)
|
|
504
|
+
* and shrink into it. Owns each item's transform: to also pop or stamp an
|
|
505
|
+
* item, wrap it and converge the wrapper. Returns the last arrival.
|
|
506
|
+
*/
|
|
507
|
+
function converge(tl, els, at, target, opts = {}) {
|
|
508
|
+
const list = Array.from(els);
|
|
509
|
+
const duration = opts.duration ?? 0.85;
|
|
510
|
+
const stagger = opts.stagger ?? 0.05;
|
|
511
|
+
const bow = opts.bow ?? 0.28;
|
|
512
|
+
const shrink = opts.shrink ?? 0.12;
|
|
513
|
+
const next = rng(opts.seed ?? 5);
|
|
514
|
+
const into = easeOf(opts.ease, "drop");
|
|
515
|
+
let last = at;
|
|
516
|
+
list.forEach((el, i) => {
|
|
517
|
+
const start = centerOf(el, opts.space ?? el.offsetParent);
|
|
518
|
+
const side = next() < 0.5 ? -1 : 1;
|
|
519
|
+
const myBow = bow * side * (0.6 + next() * 0.8);
|
|
520
|
+
const begin = at + i * stagger;
|
|
521
|
+
last = begin + duration;
|
|
522
|
+
drive(tl, begin, duration, (t) => {
|
|
523
|
+
const p = into(clamp(t / duration, 0, 1));
|
|
524
|
+
const pt = arcPoint(start, target, myBow, p);
|
|
525
|
+
const s = lerp(1, shrink, p);
|
|
526
|
+
el.style.transform = `translate(${pt.x - start.x}px, ${pt.y - start.y}px) scale(${s})`;
|
|
527
|
+
el.style.opacity = String(p >= 1 ? 0 : 1);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
return last;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ------------------------------------------------------------- contact
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Height, width-scale and height-scale of a hop at local time t: crouch,
|
|
537
|
+
* a parabola with stretch, a squash on landing, a spring back. Pure.
|
|
538
|
+
*/
|
|
539
|
+
function hopAt(t, opts = {}) {
|
|
540
|
+
const height = opts.height ?? 140;
|
|
541
|
+
const air = opts.duration ?? 0.5;
|
|
542
|
+
const squash = opts.squash ?? 0.2;
|
|
543
|
+
const crouch = 0.08;
|
|
544
|
+
const hit = 0.05;
|
|
545
|
+
if (t <= 0) return { lift: 0, sx: 1, sy: 1 };
|
|
546
|
+
if (t < crouch) {
|
|
547
|
+
const q = t / crouch;
|
|
548
|
+
return { lift: 0, sx: 1 + squash * 0.3 * q, sy: 1 - squash * 0.6 * q };
|
|
549
|
+
}
|
|
550
|
+
if (t < crouch + air) {
|
|
551
|
+
const p = (t - crouch) / air;
|
|
552
|
+
const stretch = 1 + squash * 0.5 * Math.sin(Math.PI * p);
|
|
553
|
+
return { lift: 4 * height * p * (1 - p), sx: 2 - stretch, sy: stretch };
|
|
554
|
+
}
|
|
555
|
+
const d = t - crouch - air;
|
|
556
|
+
if (d < hit) {
|
|
557
|
+
const q = d / hit;
|
|
558
|
+
return { lift: 0, sx: 1 + squash * 0.5 * q, sy: 1 - squash * q };
|
|
559
|
+
}
|
|
560
|
+
const e = spring(d - hit, "bouncy");
|
|
561
|
+
return { lift: 0, sx: lerp(1 + squash * 0.5, 1, e), sy: lerp(1 - squash, 1, e) };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Crouch, hop on a parabola, land with a squash. Owns the element's
|
|
566
|
+
* transform while it plays (wrap it to move it otherwise). Returns the
|
|
567
|
+
* landing time.
|
|
568
|
+
*/
|
|
569
|
+
function hop(tl, el, at, opts = {}) {
|
|
570
|
+
const air = opts.duration ?? 0.5;
|
|
571
|
+
const landing = at + 0.08 + air;
|
|
572
|
+
el.style.transformOrigin = "50% 100%";
|
|
573
|
+
drive(tl, at, 0.08 + air + 0.05 + settleTime("bouncy"), (t) => {
|
|
574
|
+
const h = hopAt(t, opts);
|
|
575
|
+
el.style.transform = `translate(0px, ${-h.lift}px) scale(${h.sx}, ${h.sy})`;
|
|
576
|
+
});
|
|
577
|
+
return landing;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* A hit: a short ring-down shake on `shake` (a layer that is not the
|
|
582
|
+
* camera's world; use camera shakes for that) and, with `split`, two halves
|
|
583
|
+
* thrown apart. Returns the hit time.
|
|
584
|
+
*/
|
|
585
|
+
function impact(tl, at, opts = {}) {
|
|
586
|
+
const amount = opts.amount ?? 16;
|
|
587
|
+
if (opts.shake) {
|
|
588
|
+
const el = opts.shake;
|
|
589
|
+
const length = opts.duration ?? 0.6;
|
|
590
|
+
drive(tl, at, length, (t) => {
|
|
591
|
+
const x = amount * ring(t, { freq: 13, decay: 8 });
|
|
592
|
+
const y = amount * 0.6 * ring(t, { freq: 17, decay: 9 });
|
|
593
|
+
el.style.translate = `${x}px ${y}px`;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
if (opts.split) {
|
|
597
|
+
const [left, right] = opts.split;
|
|
598
|
+
const spread = opts.spread ?? 140;
|
|
599
|
+
const duration = settleTime("soft");
|
|
600
|
+
tl.to(left, { x: -spread, rotation: -6, duration, ease: springEase("soft") }, at);
|
|
601
|
+
tl.to(right, { x: spread, rotation: 6, duration, ease: springEase("soft") }, at);
|
|
602
|
+
}
|
|
603
|
+
return at;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* A chain reaction: each element is knocked along `axis`, then springs
|
|
608
|
+
* back, and hits the next one as it moves. Returns the last contact.
|
|
609
|
+
*/
|
|
610
|
+
function knock(tl, els, at, opts = {}) {
|
|
611
|
+
const list = Array.from(els);
|
|
612
|
+
const push = opts.push ?? 48;
|
|
613
|
+
const gap = opts.gap ?? 0.11;
|
|
614
|
+
const tilt = opts.tilt ?? 7;
|
|
615
|
+
const prop = opts.axis === "y" ? "y" : "x";
|
|
616
|
+
let contact = at;
|
|
617
|
+
list.forEach((el, i) => {
|
|
618
|
+
contact = at + i * gap;
|
|
619
|
+
const decay = 1 - Math.min(0.5, i * 0.06);
|
|
620
|
+
tl.to(
|
|
621
|
+
el,
|
|
622
|
+
{ [prop]: push * decay, rotation: tilt * decay, duration: 0.08, ease: "power2.out" },
|
|
623
|
+
contact,
|
|
624
|
+
);
|
|
625
|
+
tl.to(
|
|
626
|
+
el,
|
|
627
|
+
{ [prop]: 0, rotation: 0, duration: settleTime("bouncy"), ease: springEase("bouncy") },
|
|
628
|
+
contact + 0.08,
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
return contact;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// -------------------------------------------------------------- camera
|
|
635
|
+
|
|
636
|
+
const BASE = { x: 960, y: 540, zoom: 1, rotate: 0 };
|
|
637
|
+
|
|
638
|
+
function poseOf(key) {
|
|
639
|
+
return { x: key.x ?? BASE.x, y: key.y ?? BASE.y, zoom: key.zoom ?? 1, rotate: key.rotate ?? 0 };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Prepares camera keys: each key is a move that starts at `at` (minus the
|
|
644
|
+
* lead) and takes `duration` to reach { x, y, zoom, rotate }, the world
|
|
645
|
+
* point at the centre of the frame. The first key is where it starts.
|
|
646
|
+
*/
|
|
647
|
+
function cameraPlan(keys, opts = {}) {
|
|
648
|
+
if (!keys.length) throw new Error("camera: needs at least one key");
|
|
649
|
+
const lead = opts.lead ?? 0;
|
|
650
|
+
const moves = keys.slice(1).map((key) => ({
|
|
651
|
+
start: (key.at ?? 0) - lead,
|
|
652
|
+
duration: key.duration ?? (key.move === "cut" ? 0.001 : 0.9),
|
|
653
|
+
ease: easeOf(key.move, "whip"),
|
|
654
|
+
to: poseOf(key),
|
|
655
|
+
}));
|
|
656
|
+
moves.sort((a, b) => a.start - b.start);
|
|
657
|
+
const plan = { first: poseOf(keys[0]), moves: [] };
|
|
658
|
+
for (const move of moves) {
|
|
659
|
+
plan.moves.push({ ...move, from: posePlanned(plan, move.start) });
|
|
660
|
+
}
|
|
661
|
+
return plan;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function posePlanned(plan, t) {
|
|
665
|
+
let pose = plan.first;
|
|
666
|
+
for (const move of plan.moves) {
|
|
667
|
+
if (t < move.start) break;
|
|
668
|
+
const p = easeOf(move.ease)(clamp((t - move.start) / move.duration, 0, 1));
|
|
669
|
+
pose = {
|
|
670
|
+
x: lerp(move.from.x, move.to.x, p),
|
|
671
|
+
y: lerp(move.from.y, move.to.y, p),
|
|
672
|
+
zoom: Math.max(0.05, lerp(move.from.zoom, move.to.zoom, p)),
|
|
673
|
+
rotate: lerp(move.from.rotate, move.to.rotate, p),
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
return pose;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/** 0 inside any rest, rising smoothly to 1 within `fade` seconds of it. */
|
|
680
|
+
function restEnvelope(t, rests = [], fade = 0.3) {
|
|
681
|
+
let env = 1;
|
|
682
|
+
for (const [a, b] of rests) {
|
|
683
|
+
if (t >= a && t <= b) return 0;
|
|
684
|
+
const d = t < a ? a - t : t - b;
|
|
685
|
+
if (d < fade) env = Math.min(env, ease.glide(d / fade));
|
|
686
|
+
}
|
|
687
|
+
return env;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The hand-held float: small, slow, seeded drift that is exactly zero in rests. */
|
|
691
|
+
function handheld(t, opts = {}) {
|
|
692
|
+
const seed = opts.seed ?? 7;
|
|
693
|
+
const amp = opts.amp ?? 6;
|
|
694
|
+
const rot = opts.rotate ?? 0.2;
|
|
695
|
+
const rate = opts.rate ?? 0.5;
|
|
696
|
+
const env = restEnvelope(t, opts.rests, opts.fade);
|
|
697
|
+
if (env === 0) return { x: 0, y: 0, rotate: 0 };
|
|
698
|
+
const wob = (s) =>
|
|
699
|
+
0.72 * noise(seed + s, t * rate) + 0.28 * noise(seed + s + 50, t * rate * 2.7);
|
|
700
|
+
return { x: amp * env * wob(0), y: amp * 0.8 * env * wob(1), rotate: rot * env * wob(2) };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Shake offsets at t from a list of { at, amount, decay } hits. */
|
|
704
|
+
function shakeAt(t, shakes = []) {
|
|
705
|
+
let x = 0;
|
|
706
|
+
let y = 0;
|
|
707
|
+
for (const s of shakes) {
|
|
708
|
+
const amount = s.amount ?? 14;
|
|
709
|
+
x += amount * ring(t - s.at, { freq: s.freq ?? 15, decay: s.decay ?? 9 });
|
|
710
|
+
y +=
|
|
711
|
+
amount * 0.7 * ring(t - s.at - 0.013, { freq: (s.freq ?? 15) * 1.27, decay: s.decay ?? 9 });
|
|
712
|
+
}
|
|
713
|
+
return { x, y };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Full camera state at t: planned pose plus hand-held float and shakes (screen px). */
|
|
717
|
+
function cameraAt(plan, t, opts = {}) {
|
|
718
|
+
const pose = posePlanned(plan, t);
|
|
719
|
+
const hand = opts.handheld
|
|
720
|
+
? handheld(t, { rests: opts.rests, ...opts.handheld })
|
|
721
|
+
: { x: 0, y: 0, rotate: 0 };
|
|
722
|
+
const shake = shakeAt(t, opts.shakes);
|
|
723
|
+
return {
|
|
724
|
+
...pose,
|
|
725
|
+
offsetX: hand.x + shake.x,
|
|
726
|
+
offsetY: hand.y + shake.y,
|
|
727
|
+
rotate: pose.rotate + hand.rotate,
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The camera as seen by a layer at `depth`: 1 is the world itself, 0 is
|
|
733
|
+
* pinned to the screen (a far sky), 0.5 drifts half as far, 1.4 is a near
|
|
734
|
+
* foreground that rushes past. Pan, float and shake scale with depth; zoom
|
|
735
|
+
* scales geometrically so it stays positive. Pure.
|
|
736
|
+
*/
|
|
737
|
+
function atDepth(cam, depth, width = 1920, height = 1080) {
|
|
738
|
+
const cx = width / 2;
|
|
739
|
+
const cy = height / 2;
|
|
740
|
+
return {
|
|
741
|
+
x: cx + (cam.x - cx) * depth,
|
|
742
|
+
y: cy + (cam.y - cy) * depth,
|
|
743
|
+
zoom: cam.zoom ** depth,
|
|
744
|
+
rotate: cam.rotate * depth,
|
|
745
|
+
offsetX: (cam.offsetX ?? 0) * depth,
|
|
746
|
+
offsetY: (cam.offsetY ?? 0) * depth,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** CSS transform that puts camera state `cam` on a layer with origin 0 0. */
|
|
751
|
+
function cameraTransform(cam, width = 1920, height = 1080) {
|
|
752
|
+
return `translate(${width / 2 + (cam.offsetX ?? 0)}px, ${height / 2 + (cam.offsetY ?? 0)}px) rotate(${cam.rotate}deg) scale(${cam.zoom}) translate(${-cam.x}px, ${-cam.y}px)`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** Where world point (x, y) lands on screen under camera state `cam`. */
|
|
756
|
+
function toScreen(cam, x, y, width = 1920, height = 1080) {
|
|
757
|
+
const dx = (x - cam.x) * cam.zoom;
|
|
758
|
+
const dy = (y - cam.y) * cam.zoom;
|
|
759
|
+
const r = (cam.rotate * Math.PI) / 180;
|
|
760
|
+
return {
|
|
761
|
+
x: width / 2 + (cam.offsetX ?? 0) + dx * Math.cos(r) - dy * Math.sin(r),
|
|
762
|
+
y: height / 2 + (cam.offsetY ?? 0) + dx * Math.sin(r) + dy * Math.cos(r),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* An operated camera on `world` (a layer holding the whole scene, in world
|
|
768
|
+
* coordinates). Keys say where it looks; options add lead, a hand-held
|
|
769
|
+
* float (zero in `rests`), impact `shakes`, and parallax `layers`
|
|
770
|
+
* ([{ el, depth }], each a full-frame layer in its own world coordinates;
|
|
771
|
+
* see atDepth). Returns the plan and a `toScreen(t, x, y)` helper for
|
|
772
|
+
* placing overlays.
|
|
773
|
+
*/
|
|
774
|
+
function camera(tl, world, keys, opts = {}) {
|
|
775
|
+
const width = opts.width ?? 1920;
|
|
776
|
+
const height = opts.height ?? 1080;
|
|
777
|
+
const plan = cameraPlan(keys, opts);
|
|
778
|
+
const lastMove = plan.moves.reduce((end, m) => Math.max(end, m.start + m.duration), 0);
|
|
779
|
+
const duration = opts.duration ?? lastMove + 0.01;
|
|
780
|
+
const layers = Array.from(opts.layers ?? []);
|
|
781
|
+
for (const layer of layers) {
|
|
782
|
+
if (!layer || !layer.el || !Number.isFinite(layer.depth) || layer.depth < 0)
|
|
783
|
+
throw new Error("camera: each layer needs { el, depth } with depth >= 0");
|
|
784
|
+
}
|
|
785
|
+
world.style.transformOrigin = "0 0";
|
|
786
|
+
for (const layer of layers) layer.el.style.transformOrigin = "0 0";
|
|
787
|
+
airSources.push({ tl, plan, duration, width, height });
|
|
788
|
+
queueFlush();
|
|
789
|
+
drive(tl, 0, duration, (t) => {
|
|
790
|
+
const cam = cameraAt(plan, t, opts);
|
|
791
|
+
world.style.transform = cameraTransform(cam, width, height);
|
|
792
|
+
for (const layer of layers) {
|
|
793
|
+
layer.el.style.transform = cameraTransform(
|
|
794
|
+
atDepth(cam, layer.depth, width, height),
|
|
795
|
+
width,
|
|
796
|
+
height,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
});
|
|
800
|
+
return {
|
|
801
|
+
plan,
|
|
802
|
+
at: (t) => cameraAt(plan, t, opts),
|
|
803
|
+
toScreen: (t, x, y) => toScreen(cameraAt(plan, t, opts), x, y, width, height),
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ----------------------------------------------------------- deep zoom
|
|
808
|
+
|
|
809
|
+
function finitePoint(p, label) {
|
|
810
|
+
if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y))
|
|
811
|
+
throw new Error(`${label} must be { x, y } numbers`);
|
|
812
|
+
return { x: p.x, y: p.y };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function deepZoomOpts(opts) {
|
|
816
|
+
const width = opts.width ?? 1920;
|
|
817
|
+
const height = opts.height ?? 1080;
|
|
818
|
+
const centre = { x: width / 2, y: height / 2 };
|
|
819
|
+
const from = opts.from ?? 1;
|
|
820
|
+
const to = opts.to ?? 100;
|
|
821
|
+
for (const [name, z] of [
|
|
822
|
+
["from", from],
|
|
823
|
+
["to", to],
|
|
824
|
+
]) {
|
|
825
|
+
if (!(Number.isFinite(z) && z > 0)) throw new Error(`deepZoom: ${name} zoom must be > 0`);
|
|
826
|
+
}
|
|
827
|
+
const duration = opts.duration ?? 3;
|
|
828
|
+
if (!(duration > 0)) throw new Error("deepZoom: duration must be > 0");
|
|
829
|
+
return {
|
|
830
|
+
width,
|
|
831
|
+
height,
|
|
832
|
+
anchor: finitePoint(opts.anchor ?? centre, "deepZoom: anchor"),
|
|
833
|
+
from,
|
|
834
|
+
to,
|
|
835
|
+
screenFrom: finitePoint(opts.screenFrom ?? centre, "deepZoom: screenFrom"),
|
|
836
|
+
screenTo: finitePoint(opts.screenTo ?? opts.screenFrom ?? centre, "deepZoom: screenTo"),
|
|
837
|
+
duration,
|
|
838
|
+
ease: easeOf(opts.ease, "glide"),
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Camera state at local time t of a deep zoom: the zoom is eased in log
|
|
844
|
+
* space and the anchor's screen point is eased linearly; the world point at
|
|
845
|
+
* frame centre is recovered from those, so the anchor never drifts however
|
|
846
|
+
* far the zoom goes. Same shape as cameraAt (works with toScreen). Pure.
|
|
847
|
+
*/
|
|
848
|
+
function deepZoomAt(t, opts = {}) {
|
|
849
|
+
const o = deepZoomOpts(opts);
|
|
850
|
+
const p = o.ease(clamp(t / o.duration, 0, 1));
|
|
851
|
+
const zoom = Math.exp(lerp(Math.log(o.from), Math.log(o.to), p));
|
|
852
|
+
const sx = lerp(o.screenFrom.x, o.screenTo.x, p);
|
|
853
|
+
const sy = lerp(o.screenFrom.y, o.screenTo.y, p);
|
|
854
|
+
return {
|
|
855
|
+
x: o.anchor.x - (sx - o.width / 2) / zoom,
|
|
856
|
+
y: o.anchor.y - (sy - o.height / 2) / zoom,
|
|
857
|
+
zoom,
|
|
858
|
+
rotate: 0,
|
|
859
|
+
offsetX: 0,
|
|
860
|
+
offsetY: 0,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Zooms `world` (same conventions as camera: world coordinates, origin 0 0)
|
|
866
|
+
* by any factor, keyed by where `anchor` sits on screen. Returns the end.
|
|
867
|
+
*/
|
|
868
|
+
function deepZoom(tl, world, at, opts = {}) {
|
|
869
|
+
const o = deepZoomOpts(opts);
|
|
870
|
+
world.style.transformOrigin = "0 0";
|
|
871
|
+
return drive(tl, at, o.duration, (t) => {
|
|
872
|
+
world.style.transform = cameraTransform(deepZoomAt(t, opts), o.width, o.height);
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// --------------------------------------------------------------- light
|
|
877
|
+
|
|
878
|
+
function lightOpts(opts) {
|
|
879
|
+
const rise = opts.rise ?? 0.9;
|
|
880
|
+
const hold = opts.hold ?? 0.6;
|
|
881
|
+
const drain = opts.drain ?? 0.9;
|
|
882
|
+
for (const [name, v] of [
|
|
883
|
+
["rise", rise],
|
|
884
|
+
["hold", hold],
|
|
885
|
+
["drain", drain],
|
|
886
|
+
]) {
|
|
887
|
+
if (!(Number.isFinite(v) && v >= 0)) throw new Error(`lightGround: ${name} must be >= 0`);
|
|
888
|
+
}
|
|
889
|
+
if (rise + hold + drain <= 0) throw new Error("lightGround: needs a duration");
|
|
890
|
+
return { rise, hold, drain, ease: easeOf(opts.ease, "glide") };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Light level 0..1 at local time t: rises, holds flooded, drains. Pure. */
|
|
894
|
+
function lightLevel(t, opts = {}) {
|
|
895
|
+
const o = lightOpts(opts);
|
|
896
|
+
if (t <= 0) return 0;
|
|
897
|
+
if (t < o.rise) return o.ease(t / o.rise);
|
|
898
|
+
if (t <= o.rise + o.hold) return 1;
|
|
899
|
+
if (t < o.rise + o.hold + o.drain) return 1 - o.ease((t - o.rise - o.hold) / o.drain);
|
|
900
|
+
return o.drain > 0 ? 0 : 1;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* A soft field of light anchored below the frame (x, y default centre,
|
|
905
|
+
* height + 0.25 h) that rises, floods and drains. On a canvas it paints a
|
|
906
|
+
* radial gradient; on any other element it sets --light (0..1), --light-r
|
|
907
|
+
* (px), --light-x and --light-y for its CSS to use. Returns the flood time.
|
|
908
|
+
*/
|
|
909
|
+
function lightGround(tl, el, at, opts = {}) {
|
|
910
|
+
const o = lightOpts(opts);
|
|
911
|
+
const width = opts.width ?? 1920;
|
|
912
|
+
const height = opts.height ?? 1080;
|
|
913
|
+
const x = opts.x ?? width / 2;
|
|
914
|
+
const y = opts.y ?? height * 1.25;
|
|
915
|
+
const color = opts.color ?? "255, 244, 222";
|
|
916
|
+
const full = coverRadius(x, y, width, height) * (opts.reach ?? 1.15);
|
|
917
|
+
const ctx = typeof el.getContext === "function" ? el.getContext("2d") : null;
|
|
918
|
+
drive(tl, at, o.rise + o.hold + o.drain, (t) => {
|
|
919
|
+
const level = lightLevel(t, opts);
|
|
920
|
+
const r = Math.max(1, full * level);
|
|
921
|
+
if (ctx) {
|
|
922
|
+
ctx.clearRect(0, 0, el.width, el.height);
|
|
923
|
+
if (level <= 0) return;
|
|
924
|
+
const sx = el.width / width;
|
|
925
|
+
const g = ctx.createRadialGradient(x * sx, y * sx, 0, x * sx, y * sx, r * sx);
|
|
926
|
+
g.addColorStop(0, `rgba(${color}, ${level})`);
|
|
927
|
+
g.addColorStop(0.55, `rgba(${color}, ${level * 0.85})`);
|
|
928
|
+
g.addColorStop(1, `rgba(${color}, 0)`);
|
|
929
|
+
ctx.fillStyle = g;
|
|
930
|
+
ctx.fillRect(0, 0, el.width, el.height);
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
el.style.setProperty("--light", String(level));
|
|
934
|
+
el.style.setProperty("--light-r", `${r}px`);
|
|
935
|
+
el.style.setProperty("--light-x", `${x}px`);
|
|
936
|
+
el.style.setProperty("--light-y", `${y}px`);
|
|
937
|
+
});
|
|
938
|
+
return at + o.rise;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function ringsOpts(opts) {
|
|
942
|
+
const count = opts.count ?? 4;
|
|
943
|
+
if (!(Number.isInteger(count) && count >= 1)) throw new Error("rings: count must be >= 1");
|
|
944
|
+
const duration = opts.duration ?? 1.2;
|
|
945
|
+
if (!(duration > 0)) throw new Error("rings: duration must be > 0");
|
|
946
|
+
const stagger = opts.stagger ?? 0.14;
|
|
947
|
+
if (!(stagger >= 0)) throw new Error("rings: stagger must be >= 0");
|
|
948
|
+
const width = opts.width ?? 1920;
|
|
949
|
+
const height = opts.height ?? 1080;
|
|
950
|
+
const x = opts.x ?? width / 2;
|
|
951
|
+
const y = opts.y ?? height / 2;
|
|
952
|
+
return {
|
|
953
|
+
count,
|
|
954
|
+
duration,
|
|
955
|
+
stagger,
|
|
956
|
+
x,
|
|
957
|
+
y,
|
|
958
|
+
end: coverRadius(x, y, width, height),
|
|
959
|
+
ease: easeOf(opts.ease, "snap"),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Rings at local time t: [{ r, inner }] outer and inner radius of each
|
|
965
|
+
* revealed band, ring 0 first and largest. Each ring grows from the point
|
|
966
|
+
* and fills in to a disc, so ring 0 ends covering the frame. Pure.
|
|
967
|
+
*/
|
|
968
|
+
function ringsAt(t, opts = {}) {
|
|
969
|
+
const o = ringsOpts(opts);
|
|
970
|
+
const out = [];
|
|
971
|
+
for (let i = 0; i < o.count; i++) {
|
|
972
|
+
const p = clamp((t - i * o.stagger) / o.duration, 0, 1);
|
|
973
|
+
const r = o.end * o.ease(p);
|
|
974
|
+
out.push({ r, inner: r * (1 - ease.drop(p)) });
|
|
975
|
+
}
|
|
976
|
+
return out;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Union of the ring bands as a CSS mask: opaque where revealed. */
|
|
980
|
+
function ringsMask(list, x, y) {
|
|
981
|
+
const bands = list
|
|
982
|
+
.filter((b) => b.r > 0.01)
|
|
983
|
+
.map((b) => [b.inner, b.r])
|
|
984
|
+
.sort((a, b) => a[0] - b[0]);
|
|
985
|
+
const merged = [];
|
|
986
|
+
for (const b of bands) {
|
|
987
|
+
const last = merged[merged.length - 1];
|
|
988
|
+
if (last && b[0] <= last[1]) last[1] = Math.max(last[1], b[1]);
|
|
989
|
+
else merged.push([...b]);
|
|
990
|
+
}
|
|
991
|
+
if (!merged.length)
|
|
992
|
+
return `radial-gradient(circle at ${x}px ${y}px, transparent 0, transparent 100%)`;
|
|
993
|
+
const stops = [];
|
|
994
|
+
for (const [a, b] of merged) {
|
|
995
|
+
stops.push(`transparent ${a}px`, `#000 ${a}px`, `#000 ${b}px`, `transparent ${b}px`);
|
|
996
|
+
}
|
|
997
|
+
return `radial-gradient(circle at ${x}px ${y}px, transparent 0px, ${stops.join(", ")})`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Reveals `el` (the next scene, layered above the flood) through
|
|
1002
|
+
* concentric rings expanding from (x, y), ending fully shown: an end to a
|
|
1003
|
+
* flood without a wipe. Returns the time it is fully revealed.
|
|
1004
|
+
*/
|
|
1005
|
+
function rings(tl, el, at, opts = {}) {
|
|
1006
|
+
const o = ringsOpts(opts);
|
|
1007
|
+
const total = o.duration + o.stagger * (o.count - 1);
|
|
1008
|
+
drive(tl, at, total, (t) => {
|
|
1009
|
+
const mask = ringsMask(ringsAt(t, opts), o.x, o.y);
|
|
1010
|
+
el.style.maskImage = mask;
|
|
1011
|
+
el.style.webkitMaskImage = mask;
|
|
1012
|
+
});
|
|
1013
|
+
return at + o.duration;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ---------------------------------------------------------------- silk
|
|
1017
|
+
|
|
1018
|
+
function parseColor(c) {
|
|
1019
|
+
const m = /^#?([0-9a-f]{6}|[0-9a-f]{3})$/i.exec(String(c).trim());
|
|
1020
|
+
if (!m) throw new Error(`silk: colours must be hex like #1a2b3c, got ${c}`);
|
|
1021
|
+
const h = m[1].length === 3 ? [...m[1]].map((d) => d + d).join("") : m[1];
|
|
1022
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Smooth 2D value noise in [-1, 1] from the kit's hash. */
|
|
1026
|
+
function noise2(seed, x, y) {
|
|
1027
|
+
const xi = Math.floor(x);
|
|
1028
|
+
const yi = Math.floor(y);
|
|
1029
|
+
const fade = (f) => f * f * f * (f * (f * 6 - 15) + 10);
|
|
1030
|
+
const u = fade(x - xi);
|
|
1031
|
+
const v = fade(y - yi);
|
|
1032
|
+
const h = (i, j) => hash(i * 73856093 + j * 19349663, seed);
|
|
1033
|
+
return (
|
|
1034
|
+
lerp(lerp(h(xi, yi), h(xi + 1, yi), u), lerp(h(xi, yi + 1), h(xi + 1, yi + 1), u), v) * 2 - 1
|
|
1035
|
+
);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function silkOpts(opts) {
|
|
1039
|
+
if (!Array.isArray(opts.colors) || opts.colors.length < 2)
|
|
1040
|
+
throw new Error("silk: needs colors, at least two hex colours");
|
|
1041
|
+
const cols = opts.cols ?? 64;
|
|
1042
|
+
const rows = opts.rows ?? Math.round((cols * 9) / 16);
|
|
1043
|
+
if (!(Number.isInteger(cols) && cols >= 2 && Number.isInteger(rows) && rows >= 2))
|
|
1044
|
+
throw new Error("silk: cols and rows must be integers >= 2");
|
|
1045
|
+
return {
|
|
1046
|
+
colors: opts.colors.map(parseColor),
|
|
1047
|
+
seed: opts.seed ?? 1,
|
|
1048
|
+
speed: opts.speed ?? 0.08,
|
|
1049
|
+
scale: opts.scale ?? 2.2,
|
|
1050
|
+
warp: opts.warp ?? 1.2,
|
|
1051
|
+
cols,
|
|
1052
|
+
rows,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* RGBA pixels (cols x rows) of the silk field at time t: warped seeded
|
|
1058
|
+
* noise mapped across the colours. Pure.
|
|
1059
|
+
*/
|
|
1060
|
+
function silkField(t, opts = {}) {
|
|
1061
|
+
const o = silkOpts(opts);
|
|
1062
|
+
const out = new Uint8ClampedArray(o.cols * o.rows * 4);
|
|
1063
|
+
const s = o.seed * 101;
|
|
1064
|
+
const time = t * o.speed;
|
|
1065
|
+
const last = o.colors.length - 1;
|
|
1066
|
+
for (let j = 0; j < o.rows; j++) {
|
|
1067
|
+
for (let i = 0; i < o.cols; i++) {
|
|
1068
|
+
const x = (i / o.cols) * o.scale * (o.cols / o.rows);
|
|
1069
|
+
const y = (j / o.rows) * o.scale;
|
|
1070
|
+
const qx = noise2(s + 1, x + time, y - time * 0.7);
|
|
1071
|
+
const qy = noise2(s + 2, x - time * 0.6, y + time + 5.2);
|
|
1072
|
+
const v =
|
|
1073
|
+
0.65 * noise2(s + 3, x + o.warp * qx + time * 0.3, y + o.warp * qy) +
|
|
1074
|
+
0.35 * noise2(s + 4, 2 * x - o.warp * qy, 2 * y + o.warp * qx - time * 0.5);
|
|
1075
|
+
const f = clamp(0.5 + 0.62 * v, 0, 1) * last;
|
|
1076
|
+
const k = Math.min(last - 1, Math.floor(f));
|
|
1077
|
+
const a = o.colors[k];
|
|
1078
|
+
const b = o.colors[k + 1];
|
|
1079
|
+
const p = ease.glide(f - k);
|
|
1080
|
+
const at = (j * o.cols + i) * 4;
|
|
1081
|
+
out[at] = lerp(a[0], b[0], p);
|
|
1082
|
+
out[at + 1] = lerp(a[1], b[1], p);
|
|
1083
|
+
out[at + 2] = lerp(a[2], b[2], p);
|
|
1084
|
+
out[at + 3] = 255;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
return out;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/**
|
|
1091
|
+
* A slowly flowing colour field on `canvas`: drawn into a small grid
|
|
1092
|
+
* (cols x rows backing pixels) and stretched smoothly by CSS. Returns the end.
|
|
1093
|
+
*/
|
|
1094
|
+
function silk(tl, canvas, at, duration, opts = {}) {
|
|
1095
|
+
const o = silkOpts(opts);
|
|
1096
|
+
if (!(duration > 0)) throw new Error("silk: duration must be > 0");
|
|
1097
|
+
canvas.width = o.cols;
|
|
1098
|
+
canvas.height = o.rows;
|
|
1099
|
+
canvas.style.imageRendering = "auto";
|
|
1100
|
+
const ctx = canvas.getContext("2d");
|
|
1101
|
+
const image = ctx.createImageData(o.cols, o.rows);
|
|
1102
|
+
return drive(tl, at, duration, (t) => {
|
|
1103
|
+
image.data.set(silkField(at + t, opts));
|
|
1104
|
+
ctx.putImageData(image, 0, 0);
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// ---------------------------------------------------------------- belt
|
|
1109
|
+
|
|
1110
|
+
function beltOpts(opts) {
|
|
1111
|
+
const speed = opts.speed ?? 240;
|
|
1112
|
+
if (!(Number.isFinite(speed) && speed > 0)) throw new Error("belt: speed must be > 0");
|
|
1113
|
+
const run = opts.stop ?? 4;
|
|
1114
|
+
if (!(Number.isFinite(run) && run > 0)) throw new Error("belt: stop must be after the start");
|
|
1115
|
+
let rampIn = opts.rampIn ?? 0.6;
|
|
1116
|
+
let rampOut = opts.rampOut ?? 0.9;
|
|
1117
|
+
if (!(rampIn >= 0 && rampOut >= 0)) throw new Error("belt: ramps must be >= 0");
|
|
1118
|
+
if (rampIn + rampOut > run) {
|
|
1119
|
+
const k = run / (rampIn + rampOut);
|
|
1120
|
+
rampIn *= k;
|
|
1121
|
+
rampOut *= k;
|
|
1122
|
+
}
|
|
1123
|
+
return { speed, run, rampIn, rampOut };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Distance covered while easing up from rest over `ramp` seconds, after u seconds.
|
|
1127
|
+
function rampDistance(u, ramp) {
|
|
1128
|
+
if (ramp <= 0) return u;
|
|
1129
|
+
const q = clamp(u / ramp, 0, 1);
|
|
1130
|
+
return ramp * (q / 2 - Math.sin(Math.PI * q) / (2 * Math.PI)) + Math.max(0, u - ramp);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Belt state at local time t (`stop` here is local, seconds after the
|
|
1135
|
+
* start): offset travelled (px, exact integral of the speed), current speed
|
|
1136
|
+
* (px/s) and clock (offset / speed: running seconds that slow with it). Pure.
|
|
1137
|
+
*/
|
|
1138
|
+
function beltAt(t, opts = {}) {
|
|
1139
|
+
const o = beltOpts(opts);
|
|
1140
|
+
const u = clamp(t, 0, o.run);
|
|
1141
|
+
const cruiseEnd = o.run - o.rampOut;
|
|
1142
|
+
let dist = rampDistance(Math.min(u, cruiseEnd), o.rampIn);
|
|
1143
|
+
let speed = o.rampIn > 0 ? o.speed * ease.glide(clamp(u / o.rampIn, 0, 1)) : o.speed;
|
|
1144
|
+
if (u > cruiseEnd) {
|
|
1145
|
+
// The ramp down mirrors a ramp up, run backwards from the stop.
|
|
1146
|
+
const left = o.run - u;
|
|
1147
|
+
dist += rampDistance(o.rampOut, o.rampOut) - rampDistance(left, o.rampOut);
|
|
1148
|
+
speed = o.rampOut > 0 ? o.speed * ease.glide(clamp(left / o.rampOut, 0, 1)) : 0;
|
|
1149
|
+
}
|
|
1150
|
+
if (t <= 0 || t >= o.run) speed = 0;
|
|
1151
|
+
const offset = dist * o.speed;
|
|
1152
|
+
return { offset, speed, clock: offset / o.speed };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* A carousel: `items` sit `spacing` px apart along `axis` and travel at
|
|
1157
|
+
* constant `speed` (direction -1 leftward/up), easing up from rest at `at`
|
|
1158
|
+
* and easing to a stop exactly at `stop` (timeline time). Items wrap round
|
|
1159
|
+
* a loop of `wrap` px starting at `origin`. Owns each item's transform.
|
|
1160
|
+
* Returns { stop, at(t), clock(t) } in timeline time.
|
|
1161
|
+
*/
|
|
1162
|
+
function belt(tl, items, at, opts = {}) {
|
|
1163
|
+
const list = Array.from(items);
|
|
1164
|
+
if (!list.length) throw new Error("belt: needs at least one item");
|
|
1165
|
+
const stop = opts.stop ?? at + 4;
|
|
1166
|
+
if (!(stop > at)) throw new Error("belt: stop must be after the start");
|
|
1167
|
+
const local = { ...opts, stop: stop - at };
|
|
1168
|
+
beltOpts(local);
|
|
1169
|
+
const spacing = opts.spacing ?? 360;
|
|
1170
|
+
const wrap = opts.wrap ?? spacing * list.length;
|
|
1171
|
+
if (!(spacing > 0 && wrap > 0)) throw new Error("belt: spacing and wrap must be > 0");
|
|
1172
|
+
const origin = opts.origin ?? -spacing;
|
|
1173
|
+
const dir = opts.direction ?? -1;
|
|
1174
|
+
const axis = opts.axis === "y" ? "y" : "x";
|
|
1175
|
+
drive(tl, at, stop - at, (t) => {
|
|
1176
|
+
const { offset } = beltAt(t, local);
|
|
1177
|
+
list.forEach((el, i) => {
|
|
1178
|
+
const raw = i * spacing + dir * offset - origin;
|
|
1179
|
+
const pos = origin + (((raw % wrap) + wrap) % wrap);
|
|
1180
|
+
el.style.transform = axis === "x" ? `translate(${pos}px, 0px)` : `translate(0px, ${pos}px)`;
|
|
1181
|
+
});
|
|
1182
|
+
});
|
|
1183
|
+
return {
|
|
1184
|
+
stop,
|
|
1185
|
+
at: (t) => beltAt(t - at, local),
|
|
1186
|
+
clock: (t) => beltAt(t - at, local).clock,
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// --------------------------------------------------------------- brush
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Points along an SVG path string (M L H V C Q Z, absolute or relative),
|
|
1194
|
+
* curves sampled every ~`step` px. Pure.
|
|
1195
|
+
*/
|
|
1196
|
+
function pathPoints(d, step = 6) {
|
|
1197
|
+
const tokens = String(d).match(/[MLHVCQZmlhvcqz]|-?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?/g);
|
|
1198
|
+
if (!tokens || !/[Mm]/.test(tokens[0])) throw new Error("brushStroke: path must start with M");
|
|
1199
|
+
const pts = [];
|
|
1200
|
+
let cmd = "";
|
|
1201
|
+
let i = 0;
|
|
1202
|
+
let cx = 0;
|
|
1203
|
+
let cy = 0;
|
|
1204
|
+
let sx = 0;
|
|
1205
|
+
let sy = 0;
|
|
1206
|
+
const num = () => {
|
|
1207
|
+
const v = Number(tokens[i++]);
|
|
1208
|
+
if (!Number.isFinite(v)) throw new Error(`brushStroke: bad path near "${tokens[i - 1]}"`);
|
|
1209
|
+
return v;
|
|
1210
|
+
};
|
|
1211
|
+
const curve = (fn, len) => {
|
|
1212
|
+
const n = Math.max(2, Math.ceil(len / step));
|
|
1213
|
+
for (let k = 1; k <= n; k++) pts.push(fn(k / n));
|
|
1214
|
+
};
|
|
1215
|
+
while (i < tokens.length) {
|
|
1216
|
+
if (/[A-Za-z]/.test(tokens[i])) cmd = tokens[i++];
|
|
1217
|
+
const rel = cmd === cmd.toLowerCase();
|
|
1218
|
+
const ox = rel ? cx : 0;
|
|
1219
|
+
const oy = rel ? cy : 0;
|
|
1220
|
+
switch (cmd.toUpperCase()) {
|
|
1221
|
+
case "M":
|
|
1222
|
+
cx = ox + num();
|
|
1223
|
+
cy = oy + num();
|
|
1224
|
+
sx = cx;
|
|
1225
|
+
sy = cy;
|
|
1226
|
+
pts.push({ x: cx, y: cy });
|
|
1227
|
+
cmd = rel ? "l" : "L";
|
|
1228
|
+
break;
|
|
1229
|
+
case "L":
|
|
1230
|
+
cx = ox + num();
|
|
1231
|
+
cy = oy + num();
|
|
1232
|
+
pts.push({ x: cx, y: cy });
|
|
1233
|
+
break;
|
|
1234
|
+
case "H":
|
|
1235
|
+
cx = ox + num();
|
|
1236
|
+
pts.push({ x: cx, y: cy });
|
|
1237
|
+
break;
|
|
1238
|
+
case "V":
|
|
1239
|
+
cy = oy + num();
|
|
1240
|
+
pts.push({ x: cx, y: cy });
|
|
1241
|
+
break;
|
|
1242
|
+
case "C": {
|
|
1243
|
+
const a = { x: cx, y: cy };
|
|
1244
|
+
const b = { x: ox + num(), y: oy + num() };
|
|
1245
|
+
const c = { x: ox + num(), y: oy + num() };
|
|
1246
|
+
const e = { x: ox + num(), y: oy + num() };
|
|
1247
|
+
const len =
|
|
1248
|
+
Math.hypot(b.x - a.x, b.y - a.y) +
|
|
1249
|
+
Math.hypot(c.x - b.x, c.y - b.y) +
|
|
1250
|
+
Math.hypot(e.x - c.x, e.y - c.y);
|
|
1251
|
+
curve((p) => {
|
|
1252
|
+
const u = 1 - p;
|
|
1253
|
+
return {
|
|
1254
|
+
x: u * u * u * a.x + 3 * u * u * p * b.x + 3 * u * p * p * c.x + p * p * p * e.x,
|
|
1255
|
+
y: u * u * u * a.y + 3 * u * u * p * b.y + 3 * u * p * p * c.y + p * p * p * e.y,
|
|
1256
|
+
};
|
|
1257
|
+
}, len);
|
|
1258
|
+
cx = e.x;
|
|
1259
|
+
cy = e.y;
|
|
1260
|
+
break;
|
|
1261
|
+
}
|
|
1262
|
+
case "Q": {
|
|
1263
|
+
const a = { x: cx, y: cy };
|
|
1264
|
+
const b = { x: ox + num(), y: oy + num() };
|
|
1265
|
+
const e = { x: ox + num(), y: oy + num() };
|
|
1266
|
+
const len = Math.hypot(b.x - a.x, b.y - a.y) + Math.hypot(e.x - b.x, e.y - b.y);
|
|
1267
|
+
curve((p) => {
|
|
1268
|
+
const u = 1 - p;
|
|
1269
|
+
return {
|
|
1270
|
+
x: u * u * a.x + 2 * u * p * b.x + p * p * e.x,
|
|
1271
|
+
y: u * u * a.y + 2 * u * p * b.y + p * p * e.y,
|
|
1272
|
+
};
|
|
1273
|
+
}, len);
|
|
1274
|
+
cx = e.x;
|
|
1275
|
+
cy = e.y;
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
case "Z":
|
|
1279
|
+
cx = sx;
|
|
1280
|
+
cy = sy;
|
|
1281
|
+
pts.push({ x: cx, y: cy });
|
|
1282
|
+
break;
|
|
1283
|
+
default:
|
|
1284
|
+
throw new Error(`brushStroke: unsupported path command "${cmd}"`);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
return pts;
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
/**
|
|
1291
|
+
* Prepares a brush stroke: the path resampled evenly (`spacing` px) with
|
|
1292
|
+
* arc position s 0..1, direction normals and seeded bristles. Pure.
|
|
1293
|
+
*/
|
|
1294
|
+
function brushPlan(opts = {}) {
|
|
1295
|
+
const raw = opts.path !== undefined ? pathPoints(opts.path) : opts.points;
|
|
1296
|
+
if (!Array.isArray(raw) || raw.length < 2)
|
|
1297
|
+
throw new Error("brushStroke: needs points (2 or more) or an SVG path");
|
|
1298
|
+
raw.forEach((p) => finitePoint(p, "brushStroke: each point"));
|
|
1299
|
+
const width = opts.width ?? 28;
|
|
1300
|
+
const count = opts.bristles ?? 48;
|
|
1301
|
+
if (!(width > 0)) throw new Error("brushStroke: width must be > 0");
|
|
1302
|
+
if (!(Number.isInteger(count) && count >= 1))
|
|
1303
|
+
throw new Error("brushStroke: bristles must be an integer >= 1");
|
|
1304
|
+
const spacing = opts.spacing ?? 3;
|
|
1305
|
+
const cum = [0];
|
|
1306
|
+
for (let i = 1; i < raw.length; i++)
|
|
1307
|
+
cum.push(cum[i - 1] + Math.hypot(raw[i].x - raw[i - 1].x, raw[i].y - raw[i - 1].y));
|
|
1308
|
+
const length = cum[cum.length - 1];
|
|
1309
|
+
if (!(length > 0)) throw new Error("brushStroke: path has no length");
|
|
1310
|
+
const n = Math.max(2, Math.ceil(length / spacing) + 1);
|
|
1311
|
+
const samples = [];
|
|
1312
|
+
let seg = 1;
|
|
1313
|
+
for (let k = 0; k < n; k++) {
|
|
1314
|
+
const d = (k / (n - 1)) * length;
|
|
1315
|
+
while (seg < raw.length - 1 && cum[seg] < d) seg++;
|
|
1316
|
+
const a = raw[seg - 1];
|
|
1317
|
+
const b = raw[seg];
|
|
1318
|
+
const span = cum[seg] - cum[seg - 1] || 1;
|
|
1319
|
+
const p = clamp((d - cum[seg - 1]) / span, 0, 1);
|
|
1320
|
+
const tx = b.x - a.x;
|
|
1321
|
+
const ty = b.y - a.y;
|
|
1322
|
+
const tn = Math.hypot(tx, ty) || 1;
|
|
1323
|
+
samples.push({
|
|
1324
|
+
x: lerp(a.x, b.x, p),
|
|
1325
|
+
y: lerp(a.y, b.y, p),
|
|
1326
|
+
nx: -ty / tn,
|
|
1327
|
+
ny: tx / tn,
|
|
1328
|
+
s: d / length,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
const seed = opts.seed ?? 3;
|
|
1332
|
+
const next = rng(seed);
|
|
1333
|
+
const bristles = Array.from({ length: count }, (_, i) => ({
|
|
1334
|
+
// Denser in the middle, like a loaded round brush.
|
|
1335
|
+
offset: (next() * 2 - 1) * (0.55 + 0.45 * next()),
|
|
1336
|
+
thick: 0.6 + next() * 0.9,
|
|
1337
|
+
alpha: 0.35 + next() * 0.5,
|
|
1338
|
+
hold: next(),
|
|
1339
|
+
seed: seed * 977 + i * 13,
|
|
1340
|
+
}));
|
|
1341
|
+
return {
|
|
1342
|
+
samples,
|
|
1343
|
+
bristles,
|
|
1344
|
+
width,
|
|
1345
|
+
length,
|
|
1346
|
+
dryness: opts.dryness ?? 0.65,
|
|
1347
|
+
color: opts.color ?? "#141414",
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** Brush width factor along the stroke: swells on touch-down, tapers off. Pure. */
|
|
1352
|
+
function brushWidth(s) {
|
|
1353
|
+
const touch =
|
|
1354
|
+
Math.sqrt(clamp(s / 0.07, 0, 1)) * (1 + 0.22 * Math.exp(-(((s - 0.08) / 0.06) ** 2)));
|
|
1355
|
+
const lift = clamp((1 - s) / 0.3, 0, 1) ** 0.7;
|
|
1356
|
+
return Math.max(0.12, touch * lift);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Draws the stroke up to progress k (0..1) from scratch on `ctx`. */
|
|
1360
|
+
function drawBrush(ctx, plan, k, canvas) {
|
|
1361
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
1362
|
+
if (k <= 0) return 0;
|
|
1363
|
+
const last = Math.min(plan.samples.length - 1, Math.floor(k * (plan.samples.length - 1)));
|
|
1364
|
+
if (last < 1) return 0;
|
|
1365
|
+
ctx.lineCap = "round";
|
|
1366
|
+
ctx.lineJoin = "round";
|
|
1367
|
+
ctx.strokeStyle = plan.color;
|
|
1368
|
+
let marks = 0;
|
|
1369
|
+
for (const b of plan.bristles) {
|
|
1370
|
+
ctx.globalAlpha = b.alpha;
|
|
1371
|
+
ctx.lineWidth = (plan.width / plan.bristles.length) * 2.2 * b.thick;
|
|
1372
|
+
ctx.beginPath();
|
|
1373
|
+
let down = false;
|
|
1374
|
+
for (let i = 0; i <= last; i++) {
|
|
1375
|
+
const p = plan.samples[i];
|
|
1376
|
+
// Dry brush: the further along, the more this bristle skips the paper.
|
|
1377
|
+
const dry = plan.dryness * p.s ** 1.6;
|
|
1378
|
+
const skip = 0.5 + 0.5 * noise(b.seed, p.s * 18) < dry * (0.6 + 0.8 * b.hold);
|
|
1379
|
+
if (skip) {
|
|
1380
|
+
down = false;
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1383
|
+
const w = (plan.width / 2) * brushWidth(p.s) * b.offset;
|
|
1384
|
+
const x = p.x + p.nx * w;
|
|
1385
|
+
const y = p.y + p.ny * w;
|
|
1386
|
+
if (down) {
|
|
1387
|
+
ctx.lineTo(x, y);
|
|
1388
|
+
marks++;
|
|
1389
|
+
} else {
|
|
1390
|
+
ctx.moveTo(x, y);
|
|
1391
|
+
down = true;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
ctx.stroke();
|
|
1395
|
+
}
|
|
1396
|
+
ctx.globalAlpha = 1;
|
|
1397
|
+
return marks;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
/**
|
|
1401
|
+
* A natural-media brush stroke drawn along `points` or an SVG `path` on
|
|
1402
|
+
* `canvas` (in its pixel space) from `at` over `duration`, redrawn from
|
|
1403
|
+
* scratch each frame. Returns the time the brush lifts.
|
|
1404
|
+
*/
|
|
1405
|
+
function brushStroke(tl, canvas, at, opts = {}) {
|
|
1406
|
+
const plan = brushPlan(opts);
|
|
1407
|
+
const duration = opts.duration ?? 0.8;
|
|
1408
|
+
if (!(duration > 0)) throw new Error("brushStroke: duration must be > 0");
|
|
1409
|
+
const curve = easeOf(opts.ease, "glide");
|
|
1410
|
+
const ctx = canvas.getContext("2d");
|
|
1411
|
+
return drive(tl, at, duration, (t) => {
|
|
1412
|
+
drawBrush(ctx, plan, curve(clamp(t / duration, 0, 1)), canvas);
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// --------------------------------------------------------------- grain
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* RGBA pixels of a seeded size x size grain tile: each pixel nudges
|
|
1420
|
+
* lighter or darker at alpha <= amount, enough to dither 8-bit bands. Pure.
|
|
1421
|
+
*/
|
|
1422
|
+
function grainTile(opts = {}) {
|
|
1423
|
+
const size = opts.size ?? 128;
|
|
1424
|
+
const amount = opts.amount ?? 0.05;
|
|
1425
|
+
if (!(Number.isInteger(size) && size >= 2 && size <= 1024))
|
|
1426
|
+
throw new Error("grain: size must be an integer 2..1024");
|
|
1427
|
+
if (!(amount > 0 && amount <= 1)) throw new Error("grain: amount must be 0..1");
|
|
1428
|
+
const next = rng(opts.seed ?? 9);
|
|
1429
|
+
const out = new Uint8ClampedArray(size * size * 4);
|
|
1430
|
+
for (let i = 0; i < size * size; i++) {
|
|
1431
|
+
const v = next() * 2 - 1;
|
|
1432
|
+
const shade = v < 0 ? 0 : 255;
|
|
1433
|
+
out[i * 4] = shade;
|
|
1434
|
+
out[i * 4 + 1] = shade;
|
|
1435
|
+
out[i * 4 + 2] = shade;
|
|
1436
|
+
out[i * 4 + 3] = Math.round(Math.abs(v) * amount * 255);
|
|
1437
|
+
}
|
|
1438
|
+
return out;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
/**
|
|
1442
|
+
* Lays a fixed, seeded grain tile over `el` (a full-frame overlay above dark
|
|
1443
|
+
* gradients) as a repeated background. Static: identical on every frame.
|
|
1444
|
+
* Returns the tile's data URL.
|
|
1445
|
+
*/
|
|
1446
|
+
function grain(el, opts = {}) {
|
|
1447
|
+
const size = opts.size ?? 128;
|
|
1448
|
+
const data = grainTile(opts);
|
|
1449
|
+
const doc = opts.document ?? el.ownerDocument ?? root.document;
|
|
1450
|
+
if (!doc || typeof doc.createElement !== "function")
|
|
1451
|
+
throw new Error("grain: needs a document to draw the tile");
|
|
1452
|
+
const canvas = doc.createElement("canvas");
|
|
1453
|
+
canvas.width = size;
|
|
1454
|
+
canvas.height = size;
|
|
1455
|
+
const ctx = canvas.getContext("2d");
|
|
1456
|
+
const image = ctx.createImageData(size, size);
|
|
1457
|
+
image.data.set(data);
|
|
1458
|
+
ctx.putImageData(image, 0, 0);
|
|
1459
|
+
const url = canvas.toDataURL("image/png");
|
|
1460
|
+
el.style.backgroundImage = `url(${url})`;
|
|
1461
|
+
el.style.backgroundRepeat = "repeat";
|
|
1462
|
+
el.style.backgroundSize = `${size}px ${size}px`;
|
|
1463
|
+
el.style.pointerEvents = "none";
|
|
1464
|
+
el.style.imageRendering = "pixelated";
|
|
1465
|
+
return url;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// --------------------------------------------------------------- print
|
|
1469
|
+
|
|
1470
|
+
// A risograph / screen-print look: flat spot inks that overprint (multiply),
|
|
1471
|
+
// a halftone screen, a little misregistration, and paper underneath.
|
|
1472
|
+
|
|
1473
|
+
function screenOpts(opts) {
|
|
1474
|
+
const cell = opts.cell ?? 7;
|
|
1475
|
+
const angle = opts.angle ?? 15;
|
|
1476
|
+
const amount = opts.amount ?? 0.86;
|
|
1477
|
+
if (!(cell >= 2 && cell <= 64)) throw new Error("printInk: cell must be 2..64 px");
|
|
1478
|
+
if (!Number.isFinite(angle)) throw new Error("printInk: angle must be a number (degrees)");
|
|
1479
|
+
if (!(amount > 0 && amount <= 1)) throw new Error("printInk: amount must be 0..1");
|
|
1480
|
+
// A screen tiles seamlessly when its lattice step is a whole-pixel vector
|
|
1481
|
+
// (a, b): the square tile is then a*a + b*b pixels wide.
|
|
1482
|
+
const rad = (angle * Math.PI) / 180;
|
|
1483
|
+
let a = Math.round(cell * Math.cos(rad));
|
|
1484
|
+
let b = Math.round(cell * Math.sin(rad));
|
|
1485
|
+
if (a === 0 && b === 0) a = 1;
|
|
1486
|
+
const size = a * a + b * b;
|
|
1487
|
+
if (size > 2048) throw new Error("printInk: cell too large for a seamless tile");
|
|
1488
|
+
return { a, b, size, amount, rough: opts.rough ?? 0.18, seed: opts.seed ?? 3 };
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/**
|
|
1492
|
+
* RGBA pixels of a seamless halftone screen tile (`size` x `size`, returned
|
|
1493
|
+
* as { size, data }): black dots whose alpha covers about `amount` of the
|
|
1494
|
+
* area, at `angle` degrees with `cell` px pitch; `rough` frays dot edges
|
|
1495
|
+
* with seeded per-pixel noise. Alpha is the ink. Pure.
|
|
1496
|
+
*/
|
|
1497
|
+
function halftoneTile(opts = {}) {
|
|
1498
|
+
const o = screenOpts(opts);
|
|
1499
|
+
const { a, b, size } = o;
|
|
1500
|
+
// Dot radius (in cells) for the asked coverage; past ~0.785 dots merge.
|
|
1501
|
+
const r = Math.min(0.75, Math.sqrt(o.amount / Math.PI));
|
|
1502
|
+
const phaseU = hash(1, o.seed);
|
|
1503
|
+
const phaseV = hash(2, o.seed);
|
|
1504
|
+
const out = new Uint8ClampedArray(size * size * 4);
|
|
1505
|
+
const ss = [0.25, 0.75];
|
|
1506
|
+
for (let y = 0; y < size; y++) {
|
|
1507
|
+
for (let x = 0; x < size; x++) {
|
|
1508
|
+
const edge = (hash(y * size + x, o.seed) * 2 - 1) * o.rough * 0.5;
|
|
1509
|
+
let ink = 0;
|
|
1510
|
+
for (const sy of ss)
|
|
1511
|
+
for (const sx of ss) {
|
|
1512
|
+
const px = x + sx;
|
|
1513
|
+
const py = y + sy;
|
|
1514
|
+
const u = (px * a + py * b) / size + phaseU;
|
|
1515
|
+
const v = (-px * b + py * a) / size + phaseV;
|
|
1516
|
+
const d = Math.hypot(u - Math.round(u), v - Math.round(v));
|
|
1517
|
+
if (d < r + edge) ink++;
|
|
1518
|
+
}
|
|
1519
|
+
const at = (y * size + x) * 4;
|
|
1520
|
+
out[at + 3] = Math.round((ink / 4) * 255);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
return { size, data: out };
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function tileURL(doc, size, data, who) {
|
|
1527
|
+
if (!doc || typeof doc.createElement !== "function")
|
|
1528
|
+
throw new Error(`${who}: needs a document to draw the tile`);
|
|
1529
|
+
const canvas = doc.createElement("canvas");
|
|
1530
|
+
canvas.width = size;
|
|
1531
|
+
canvas.height = size;
|
|
1532
|
+
const ctx = canvas.getContext("2d");
|
|
1533
|
+
const image = ctx.createImageData(size, size);
|
|
1534
|
+
image.data.set(data);
|
|
1535
|
+
ctx.putImageData(image, 0, 0);
|
|
1536
|
+
return canvas.toDataURL("image/png");
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Makes `el` a printed spot-ink layer: multiply blending (overlapping inks
|
|
1541
|
+
* darken, like overprinting), a halftone screen as its mask, and a fixed
|
|
1542
|
+
* misregistration `offset` {x, y} via the CSS `translate` property (so it
|
|
1543
|
+
* stacks with `misregister`'s transforms). Static. Returns the tile URL.
|
|
1544
|
+
*/
|
|
1545
|
+
function printInk(el, opts = {}) {
|
|
1546
|
+
const offset = opts.offset ?? { x: 0, y: 0 };
|
|
1547
|
+
if (!(Number.isFinite(offset.x) && Number.isFinite(offset.y)))
|
|
1548
|
+
throw new Error("printInk: offset must be { x, y } numbers");
|
|
1549
|
+
const { size, data } = halftoneTile(opts);
|
|
1550
|
+
const doc = opts.document ?? el.ownerDocument ?? root.document;
|
|
1551
|
+
const url = tileURL(doc, size, data, "printInk");
|
|
1552
|
+
const mask = `url(${url})`;
|
|
1553
|
+
el.style.mixBlendMode = "multiply";
|
|
1554
|
+
el.style.maskImage = mask;
|
|
1555
|
+
el.style.webkitMaskImage = mask;
|
|
1556
|
+
el.style.maskRepeat = "repeat";
|
|
1557
|
+
el.style.webkitMaskRepeat = "repeat";
|
|
1558
|
+
el.style.maskSize = `${size}px ${size}px`;
|
|
1559
|
+
el.style.webkitMaskSize = `${size}px ${size}px`;
|
|
1560
|
+
el.style.maskMode = "alpha";
|
|
1561
|
+
el.style.translate = `${offset.x}px ${offset.y}px`;
|
|
1562
|
+
return url;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function misregisterOpts(count, opts) {
|
|
1566
|
+
if (!(Number.isInteger(count) && count >= 1))
|
|
1567
|
+
throw new Error("misregister: needs at least one layer");
|
|
1568
|
+
const distance = opts.distance ?? 26;
|
|
1569
|
+
if (!(distance >= 0)) throw new Error("misregister: distance must be >= 0");
|
|
1570
|
+
let from = opts.from;
|
|
1571
|
+
if (from !== undefined) {
|
|
1572
|
+
if (!Array.isArray(from) || from.length !== count)
|
|
1573
|
+
throw new Error("misregister: from must give one { x, y } per layer");
|
|
1574
|
+
if (!from.every((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y)))
|
|
1575
|
+
throw new Error("misregister: from must give one { x, y } per layer");
|
|
1576
|
+
} else {
|
|
1577
|
+
// Seeded directions spread around the circle, so layers part ways.
|
|
1578
|
+
const next = rng(opts.seed ?? 11);
|
|
1579
|
+
const turn = next() * Math.PI * 2;
|
|
1580
|
+
from = Array.from({ length: count }, (_, i) => {
|
|
1581
|
+
const ang = turn + (i / count) * Math.PI * 2 + (next() - 0.5) * 0.6;
|
|
1582
|
+
const d = distance * (0.7 + next() * 0.3);
|
|
1583
|
+
return { x: Math.cos(ang) * d, y: Math.sin(ang) * d };
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
const spec = opts.spring ?? (opts.ease ? null : "soft");
|
|
1587
|
+
const curve = spec ? springEase(spec) : easeOf(opts.ease);
|
|
1588
|
+
const duration = opts.duration ?? (spec ? settleTime(spec) : 0.8);
|
|
1589
|
+
if (!(duration > 0)) throw new Error("misregister: duration must be > 0");
|
|
1590
|
+
return { from, curve, duration, apart: opts.apart ?? false };
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Offsets {x, y} of `count` layers t seconds into a misregister move: they
|
|
1595
|
+
* start at `from` and settle to 0 (or with `apart`, drift from 0 to `from`). Pure.
|
|
1596
|
+
*/
|
|
1597
|
+
function misregisterAt(t, count, opts = {}) {
|
|
1598
|
+
const o = misregisterOpts(count, opts);
|
|
1599
|
+
const p = o.curve(clamp(t / o.duration, 0, 1));
|
|
1600
|
+
const k = o.apart ? p : 1 - p;
|
|
1601
|
+
return o.from.map((f) => ({ x: f.x * k + 0, y: f.y * k + 0 }));
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
/**
|
|
1605
|
+
* Animates print registration of `layers` from `at`: apart by `distance`
|
|
1606
|
+
* (or explicit `from` offsets), settling together on a spring (default
|
|
1607
|
+
* "soft") or `ease` over `duration`. Returns the settle time.
|
|
1608
|
+
*/
|
|
1609
|
+
function misregister(tl, layers, at, opts = {}) {
|
|
1610
|
+
const list = Array.from(layers ?? []);
|
|
1611
|
+
const o = misregisterOpts(list.length, opts);
|
|
1612
|
+
return drive(tl, at, o.duration, (t) => {
|
|
1613
|
+
misregisterAt(t, list.length, opts).forEach((p, i) => {
|
|
1614
|
+
list[i].style.transform = `translate(${p.x.toFixed(2)}px, ${p.y.toFixed(2)}px)`;
|
|
1615
|
+
});
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/**
|
|
1620
|
+
* RGBA pixels of a seamless size x size paper tile: warm `tint` with soft
|
|
1621
|
+
* seeded mottling and short fibres, darkened by up to `amount`. Pure.
|
|
1622
|
+
*/
|
|
1623
|
+
function paperTile(opts = {}) {
|
|
1624
|
+
const size = opts.size ?? 256;
|
|
1625
|
+
const amount = opts.amount ?? 0.06;
|
|
1626
|
+
if (!(Number.isInteger(size) && size >= 8 && size <= 1024))
|
|
1627
|
+
throw new Error("paperTexture: size must be an integer 8..1024");
|
|
1628
|
+
if (!(amount > 0 && amount <= 1)) throw new Error("paperTexture: amount must be 0..1");
|
|
1629
|
+
const tint = (() => {
|
|
1630
|
+
try {
|
|
1631
|
+
return parseColor(opts.tint ?? "#fdf8ee");
|
|
1632
|
+
} catch {
|
|
1633
|
+
throw new Error("paperTexture: tint must be hex like #fbf3e4");
|
|
1634
|
+
}
|
|
1635
|
+
})();
|
|
1636
|
+
const fibres = opts.fibres ?? 160;
|
|
1637
|
+
if (!(Number.isInteger(fibres) && fibres >= 0))
|
|
1638
|
+
throw new Error("paperTexture: fibres must be an integer >= 0");
|
|
1639
|
+
const seed = opts.seed ?? 5;
|
|
1640
|
+
const dark = new Float32Array(size * size);
|
|
1641
|
+
// Periodic mottling: noise sampled on a wrapped lattice.
|
|
1642
|
+
const cells = 8;
|
|
1643
|
+
const wrapNoise = (s, x, y, n) => {
|
|
1644
|
+
const xi = Math.floor(x);
|
|
1645
|
+
const yi = Math.floor(y);
|
|
1646
|
+
const f = (q) => q * q * (3 - 2 * q);
|
|
1647
|
+
const u = f(x - xi);
|
|
1648
|
+
const v = f(y - yi);
|
|
1649
|
+
const h = (i, j) => hash((((i % n) + n) % n) * 7919 + (((j % n) + n) % n), s);
|
|
1650
|
+
return lerp(lerp(h(xi, yi), h(xi + 1, yi), u), lerp(h(xi, yi + 1), h(xi + 1, yi + 1), u), v);
|
|
1651
|
+
};
|
|
1652
|
+
for (let y = 0; y < size; y++)
|
|
1653
|
+
for (let x = 0; x < size; x++) {
|
|
1654
|
+
const m =
|
|
1655
|
+
0.6 * wrapNoise(seed, (x / size) * cells, (y / size) * cells, cells) +
|
|
1656
|
+
0.4 * wrapNoise(seed + 1, (x / size) * cells * 4, (y / size) * cells * 4, cells * 4);
|
|
1657
|
+
dark[y * size + x] = 0.45 * m + 0.25 * hash(y * size + x, seed + 2);
|
|
1658
|
+
}
|
|
1659
|
+
const next = rng(seed + 3);
|
|
1660
|
+
for (let f = 0; f < fibres; f++) {
|
|
1661
|
+
let x = next() * size;
|
|
1662
|
+
let y = next() * size;
|
|
1663
|
+
let ang = next() * Math.PI * 2;
|
|
1664
|
+
const len = 6 + next() * 22;
|
|
1665
|
+
const w = (next() < 0.5 ? -0.5 : 0.7) * (0.4 + next() * 0.6);
|
|
1666
|
+
for (let s = 0; s < len; s++) {
|
|
1667
|
+
const i = ((Math.floor(y) % size) + size) % size;
|
|
1668
|
+
const j = ((Math.floor(x) % size) + size) % size;
|
|
1669
|
+
dark[i * size + j] += w;
|
|
1670
|
+
ang += (next() - 0.5) * 0.35;
|
|
1671
|
+
x += Math.cos(ang);
|
|
1672
|
+
y += Math.sin(ang);
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
const out = new Uint8ClampedArray(size * size * 4);
|
|
1676
|
+
for (let i = 0; i < size * size; i++) {
|
|
1677
|
+
const k = 1 - amount * clamp(dark[i], 0, 1.5);
|
|
1678
|
+
out[i * 4] = tint[0] * k;
|
|
1679
|
+
out[i * 4 + 1] = tint[1] * k;
|
|
1680
|
+
out[i * 4 + 2] = tint[2] * k;
|
|
1681
|
+
out[i * 4 + 3] = 255;
|
|
1682
|
+
}
|
|
1683
|
+
return out;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
/**
|
|
1687
|
+
* Lays a fixed seeded paper tile over `el` (a full-frame overlay on top of
|
|
1688
|
+
* the inks) with multiply blending: warm stock, mottling and fibres.
|
|
1689
|
+
* Static: identical on every frame. Returns the tile's data URL.
|
|
1690
|
+
*/
|
|
1691
|
+
function paperTexture(el, opts = {}) {
|
|
1692
|
+
const size = opts.size ?? 256;
|
|
1693
|
+
const data = paperTile(opts);
|
|
1694
|
+
const doc = opts.document ?? el.ownerDocument ?? root.document;
|
|
1695
|
+
const url = tileURL(doc, size, data, "paperTexture");
|
|
1696
|
+
el.style.backgroundImage = `url(${url})`;
|
|
1697
|
+
el.style.backgroundRepeat = "repeat";
|
|
1698
|
+
el.style.backgroundSize = `${size}px ${size}px`;
|
|
1699
|
+
el.style.mixBlendMode = "multiply";
|
|
1700
|
+
el.style.pointerEvents = "none";
|
|
1701
|
+
return url;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
// -------------------------------------------------------------- cursor
|
|
1705
|
+
|
|
1706
|
+
/**
|
|
1707
|
+
* Plans a cursor that travels on gentle arcs between stops, pressing where
|
|
1708
|
+
* `click` is set. Stops are { x, y } points or { el } elements (their
|
|
1709
|
+
* centre, in `space`), with optional `hold` (pause after arriving) and
|
|
1710
|
+
* `duration` (travel time). Pure; used by cursorPath.
|
|
1711
|
+
*/
|
|
1712
|
+
function cursorPlan(stops, opts = {}) {
|
|
1713
|
+
const speed = opts.speed ?? 1500;
|
|
1714
|
+
const press = opts.press ?? 0.09;
|
|
1715
|
+
const release = opts.release ?? 0.26;
|
|
1716
|
+
const points = stops.map((s) =>
|
|
1717
|
+
s.el ? { ...centerOf(s.el, opts.space ?? s.el.offsetParent), ...s } : s,
|
|
1718
|
+
);
|
|
1719
|
+
const legs = [];
|
|
1720
|
+
const presses = [];
|
|
1721
|
+
let t = points[0].hold ?? 0;
|
|
1722
|
+
if (points[0].click) {
|
|
1723
|
+
presses.push(t);
|
|
1724
|
+
t += press + release;
|
|
1725
|
+
}
|
|
1726
|
+
for (let i = 1; i < points.length; i++) {
|
|
1727
|
+
const a = points[i - 1];
|
|
1728
|
+
const b = points[i];
|
|
1729
|
+
const dist = Math.hypot(b.x - a.x, b.y - a.y);
|
|
1730
|
+
const duration = b.duration ?? clamp(dist / speed, 0.35, 1.3);
|
|
1731
|
+
legs.push({ start: t, duration, a, b, bow: (opts.bow ?? 0.12) * (i % 2 ? 1 : -1) });
|
|
1732
|
+
t += duration;
|
|
1733
|
+
if (b.click) {
|
|
1734
|
+
presses.push(t + 0.06);
|
|
1735
|
+
t += 0.06 + press + release;
|
|
1736
|
+
}
|
|
1737
|
+
t += b.hold ?? 0.15;
|
|
1738
|
+
}
|
|
1739
|
+
return { points, legs, presses, press, release, duration: t };
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
/** Cursor position and press scale at local time t. */
|
|
1743
|
+
function cursorAt(plan, t) {
|
|
1744
|
+
let pos = { x: plan.points[0].x, y: plan.points[0].y };
|
|
1745
|
+
for (const leg of plan.legs) {
|
|
1746
|
+
if (t < leg.start) break;
|
|
1747
|
+
const p = ease.travel(clamp((t - leg.start) / leg.duration, 0, 1));
|
|
1748
|
+
pos = arcPoint(leg.a, leg.b, leg.bow, p);
|
|
1749
|
+
}
|
|
1750
|
+
let scale = 1;
|
|
1751
|
+
for (const at of plan.presses) {
|
|
1752
|
+
const d = t - at;
|
|
1753
|
+
if (d < 0 || d > plan.press + plan.release) continue;
|
|
1754
|
+
scale =
|
|
1755
|
+
d < plan.press
|
|
1756
|
+
? lerp(1, 0.8, d / plan.press)
|
|
1757
|
+
: lerp(0.8, 1, ease.bounce((d - plan.press) / plan.release));
|
|
1758
|
+
}
|
|
1759
|
+
return { x: pos.x, y: pos.y, scale };
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Moves a cursor element (its hotspot at `tip`, default top-left + [6, 4])
|
|
1764
|
+
* through the stops from `at`. Returns { end, presses } in timeline time:
|
|
1765
|
+
* script each pressed state (and its sound) from the press times.
|
|
1766
|
+
*/
|
|
1767
|
+
function cursorPath(tl, cursor, at, stops, opts = {}) {
|
|
1768
|
+
const plan = cursorPlan(stops, opts);
|
|
1769
|
+
const [tx, ty] = opts.tip ?? [6, 4];
|
|
1770
|
+
cursor.style.transformOrigin = `${tx}px ${ty}px`;
|
|
1771
|
+
const end = drive(tl, at, plan.duration, (t) => {
|
|
1772
|
+
const c = cursorAt(plan, t);
|
|
1773
|
+
cursor.style.left = `${c.x - tx}px`;
|
|
1774
|
+
cursor.style.top = `${c.y - ty}px`;
|
|
1775
|
+
cursor.style.transform = `scale(${c.scale})`;
|
|
1776
|
+
});
|
|
1777
|
+
return { end, presses: plan.presses.map((p) => at + p) };
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
// ---------------------------------------------------------------- sound cues
|
|
1781
|
+
|
|
1782
|
+
// Sound comes from the picture: mark the moments that deserve a sound where
|
|
1783
|
+
// the animation creates them, and `cues.mjs` exports them for the score.
|
|
1784
|
+
const cueMarks = [];
|
|
1785
|
+
const airSources = [];
|
|
1786
|
+
let cueFlushQueued = false;
|
|
1787
|
+
const AIR_RATE = 50;
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* How fast the keyed camera move carries the picture past the viewer, in
|
|
1791
|
+
* screen px/s: the pan at the current zoom plus the zoom's push at the frame
|
|
1792
|
+
* edge. Hand float and shakes are left out; the air follows the operated move.
|
|
1793
|
+
*/
|
|
1794
|
+
function cameraSpeed(plan, t, width = 1920, height = 1080) {
|
|
1795
|
+
const dt = 1 / (AIR_RATE * 2);
|
|
1796
|
+
const a = posePlanned(plan, Math.max(0, t - dt));
|
|
1797
|
+
const b = posePlanned(plan, t + dt);
|
|
1798
|
+
const span = 2 * dt;
|
|
1799
|
+
const zoom = (a.zoom + b.zoom) / 2;
|
|
1800
|
+
const pan = (Math.hypot(b.x - a.x, b.y - a.y) * zoom) / span;
|
|
1801
|
+
const push = (Math.abs(Math.log(b.zoom / a.zoom)) * Math.hypot(width, height)) / 2 / span;
|
|
1802
|
+
return Math.hypot(pan, push);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
/** The camera's speed curve on the root timeline, or null without a camera. */
|
|
1806
|
+
function air() {
|
|
1807
|
+
if (!airSources.length) return null;
|
|
1808
|
+
const spans = airSources.map((s) => ({ ...s, start: rootTime(s.tl, 0) }));
|
|
1809
|
+
const start = Math.min(...spans.map((s) => s.start));
|
|
1810
|
+
const end = Math.max(...spans.map((s) => s.start + s.duration));
|
|
1811
|
+
const values = [];
|
|
1812
|
+
for (let i = 0; i <= Math.ceil((end - start) * AIR_RATE); i++) {
|
|
1813
|
+
const t = start + i / AIR_RATE;
|
|
1814
|
+
let v = 0;
|
|
1815
|
+
for (const s of spans) {
|
|
1816
|
+
if (t >= s.start && t <= s.start + s.duration)
|
|
1817
|
+
v = Math.max(v, cameraSpeed(s.plan, t - s.start, s.width, s.height));
|
|
1818
|
+
}
|
|
1819
|
+
values.push(Math.round(v));
|
|
1820
|
+
}
|
|
1821
|
+
return { rate: AIR_RATE, start: Math.round(start * 10000) / 10000, values };
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/** Time `at` on `tl`, mapped onto the root timeline through any nesting. */
|
|
1825
|
+
function rootTime(tl, at) {
|
|
1826
|
+
let t = at;
|
|
1827
|
+
for (let node = tl; node && node.parent && node.parent.parent; node = node.parent) {
|
|
1828
|
+
t = node.startTime() + t / node.timeScale();
|
|
1829
|
+
}
|
|
1830
|
+
return t;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/** Every cue so far, in root-timeline seconds, sorted by time. */
|
|
1834
|
+
function cues() {
|
|
1835
|
+
return cueMarks
|
|
1836
|
+
.map(({ tl, at, sfx, gain, pan, send, name }) => ({
|
|
1837
|
+
t: Math.round(rootTime(tl, at) * 10000) / 10000,
|
|
1838
|
+
sfx,
|
|
1839
|
+
gain,
|
|
1840
|
+
pan,
|
|
1841
|
+
...(send === undefined ? {} : { send }),
|
|
1842
|
+
...(name ? { name } : {}),
|
|
1843
|
+
}))
|
|
1844
|
+
.sort((a, b) => a.t - b.t || (a.sfx < b.sfx ? -1 : a.sfx > b.sfx ? 1 : 0));
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function flushCues() {
|
|
1848
|
+
cueFlushQueued = false;
|
|
1849
|
+
const doc = root.document;
|
|
1850
|
+
if (!doc || !doc.documentElement) return;
|
|
1851
|
+
if (cueMarks.length) doc.documentElement.setAttribute("data-ez-cues", JSON.stringify(cues()));
|
|
1852
|
+
const curve = air();
|
|
1853
|
+
if (curve) doc.documentElement.setAttribute("data-ez-air", JSON.stringify(curve));
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// Written once the composition script has finished nesting its timelines.
|
|
1857
|
+
function queueFlush() {
|
|
1858
|
+
if (!cueFlushQueued && typeof root.queueMicrotask === "function") {
|
|
1859
|
+
cueFlushQueued = true;
|
|
1860
|
+
root.queueMicrotask(flushCues);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Mark a sound at time `at` of `tl` ("click", "impact", ...; the score's
|
|
1866
|
+
* sound names). Adds no tween, so seeking is unaffected. Returns `at`.
|
|
1867
|
+
* Options: `gain` 0..2; `pan` -1..1, or `x` (screen px; pans with the
|
|
1868
|
+
* picture); `send` 0..1, how far away it sounds (default: the sound's own).
|
|
1869
|
+
*/
|
|
1870
|
+
function cue(tl, at, sfx, opts = {}) {
|
|
1871
|
+
if (!(Number.isFinite(at) && at >= 0)) throw new Error(`cue time must be >= 0, got ${at}`);
|
|
1872
|
+
if (typeof sfx !== "string" || !/^[a-z][a-z-]*$/.test(sfx))
|
|
1873
|
+
throw new Error(`cue sound must be a sound name, got ${sfx}`);
|
|
1874
|
+
const gain = opts.gain ?? 1;
|
|
1875
|
+
if (opts.x !== undefined && !Number.isFinite(opts.x)) throw new Error("cue x must be a number");
|
|
1876
|
+
const width = opts.width ?? 1920;
|
|
1877
|
+
const fromX =
|
|
1878
|
+
opts.x === undefined ? 0 : Math.max(-1, Math.min(1, (opts.x / width) * 2 - 1)) * 0.7;
|
|
1879
|
+
const pan = opts.pan ?? Math.round(fromX * 1000) / 1000;
|
|
1880
|
+
if (!(gain >= 0 && gain <= 2)) throw new Error("cue gain must be 0..2");
|
|
1881
|
+
if (!(pan >= -1 && pan <= 1)) throw new Error("cue pan must be -1..1");
|
|
1882
|
+
if (opts.send !== undefined && !(opts.send >= 0 && opts.send <= 1))
|
|
1883
|
+
throw new Error("cue send must be 0..1");
|
|
1884
|
+
cueMarks.push({
|
|
1885
|
+
tl,
|
|
1886
|
+
at,
|
|
1887
|
+
sfx,
|
|
1888
|
+
gain,
|
|
1889
|
+
pan,
|
|
1890
|
+
send: opts.send,
|
|
1891
|
+
name: opts.name ? String(opts.name).slice(0, 60) : "",
|
|
1892
|
+
});
|
|
1893
|
+
queueFlush();
|
|
1894
|
+
return at;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
root.GGMotionKit = {
|
|
1898
|
+
version: 1,
|
|
1899
|
+
// timing
|
|
1900
|
+
springs,
|
|
1901
|
+
spring,
|
|
1902
|
+
settleTime,
|
|
1903
|
+
springEase,
|
|
1904
|
+
ring,
|
|
1905
|
+
ease,
|
|
1906
|
+
rng,
|
|
1907
|
+
noise,
|
|
1908
|
+
// elements
|
|
1909
|
+
centerOf,
|
|
1910
|
+
rectOf,
|
|
1911
|
+
split,
|
|
1912
|
+
drive,
|
|
1913
|
+
// arrivals
|
|
1914
|
+
liftWords,
|
|
1915
|
+
dropLetters,
|
|
1916
|
+
reveal,
|
|
1917
|
+
typePlan,
|
|
1918
|
+
typewrite,
|
|
1919
|
+
tick,
|
|
1920
|
+
pop,
|
|
1921
|
+
stamp,
|
|
1922
|
+
// handoffs
|
|
1923
|
+
reshape,
|
|
1924
|
+
coverRadius,
|
|
1925
|
+
openFrom,
|
|
1926
|
+
diveThrough,
|
|
1927
|
+
arcPoint,
|
|
1928
|
+
converge,
|
|
1929
|
+
// contact
|
|
1930
|
+
hopAt,
|
|
1931
|
+
hop,
|
|
1932
|
+
impact,
|
|
1933
|
+
knock,
|
|
1934
|
+
// camera
|
|
1935
|
+
cameraPlan,
|
|
1936
|
+
cameraAt,
|
|
1937
|
+
restEnvelope,
|
|
1938
|
+
handheld,
|
|
1939
|
+
shakeAt,
|
|
1940
|
+
atDepth,
|
|
1941
|
+
toScreen,
|
|
1942
|
+
camera,
|
|
1943
|
+
cameraSpeed,
|
|
1944
|
+
// deep zoom
|
|
1945
|
+
deepZoomAt,
|
|
1946
|
+
deepZoom,
|
|
1947
|
+
// light
|
|
1948
|
+
lightLevel,
|
|
1949
|
+
lightGround,
|
|
1950
|
+
ringsAt,
|
|
1951
|
+
rings,
|
|
1952
|
+
// fields
|
|
1953
|
+
silkField,
|
|
1954
|
+
silk,
|
|
1955
|
+
grainTile,
|
|
1956
|
+
grain,
|
|
1957
|
+
// print
|
|
1958
|
+
halftoneTile,
|
|
1959
|
+
printInk,
|
|
1960
|
+
misregisterAt,
|
|
1961
|
+
misregister,
|
|
1962
|
+
paperTile,
|
|
1963
|
+
paperTexture,
|
|
1964
|
+
// belt
|
|
1965
|
+
beltAt,
|
|
1966
|
+
belt,
|
|
1967
|
+
// brush
|
|
1968
|
+
pathPoints,
|
|
1969
|
+
brushPlan,
|
|
1970
|
+
brushWidth,
|
|
1971
|
+
brushStroke,
|
|
1972
|
+
// cursor
|
|
1973
|
+
cursorPlan,
|
|
1974
|
+
cursorAt,
|
|
1975
|
+
cursorPath,
|
|
1976
|
+
// sound
|
|
1977
|
+
cue,
|
|
1978
|
+
cues,
|
|
1979
|
+
air,
|
|
1980
|
+
};
|
|
1981
|
+
})(typeof window !== "undefined" ? window : globalThis);
|