create-nativecore 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -14
- package/bin/index.mjs +402 -431
- package/package.json +3 -2
- package/template/.env.example +28 -0
- package/template/.htmlhintrc +14 -0
- package/template/api/data/dashboard.json +11 -0
- package/template/api/data/users.json +18 -0
- package/template/api/mockApi.js +161 -0
- package/template/assets/icon.svg +13 -0
- package/template/assets/logo.svg +25 -0
- package/template/eslint.config.js +94 -0
- package/template/index.html +137 -0
- package/template/manifest.json +19 -0
- package/template/public/.well-known/security.txt +9 -0
- package/template/public/_headers +24 -0
- package/template/public/_redirects +14 -0
- package/template/public/assets/icon.svg +13 -0
- package/template/public/assets/logo.svg +25 -0
- package/template/public/manifest.json +19 -0
- package/template/public/robots.txt +13 -0
- package/template/public/sitemap.xml +27 -0
- package/template/scripts/build-for-bots.mjs +121 -0
- package/template/scripts/convert-to-ts.mjs +106 -0
- package/template/scripts/fix-encoding.mjs +38 -0
- package/template/scripts/fix-svg-paths.mjs +32 -0
- package/template/scripts/generate-cf-router.mjs +52 -0
- package/template/scripts/inject-dev-tools.mjs +41 -0
- package/template/scripts/inject-version.mjs +65 -0
- package/template/scripts/make-component.mjs +445 -0
- package/template/scripts/make-component.mjs.backup +432 -0
- package/template/scripts/make-controller.mjs +119 -0
- package/template/scripts/make-core-component.mjs +303 -0
- package/template/scripts/make-view.mjs +346 -0
- package/template/scripts/minify.mjs +71 -0
- package/template/scripts/prepare-static-assets.mjs +141 -0
- package/template/scripts/prompt-bot-build.mjs +223 -0
- package/template/scripts/remove-component.mjs +170 -0
- package/template/scripts/remove-core-component.mjs +156 -0
- package/template/scripts/remove-dev.mjs +13 -0
- package/template/scripts/remove-view.mjs +200 -0
- package/template/scripts/strip-dev-blocks.mjs +30 -0
- package/template/scripts/watch-compile.mjs +69 -0
- package/template/server.js +1066 -0
- package/template/src/app.ts +115 -0
- package/template/src/components/appRegistry.ts +8 -0
- package/template/src/components/core/app-footer.ts +27 -0
- package/template/src/components/core/app-header.ts +175 -0
- package/template/src/components/core/app-sidebar.ts +238 -0
- package/template/src/components/core/loading-spinner.ts +25 -0
- package/template/src/components/core/nc-a.ts +313 -0
- package/template/src/components/core/nc-accordion.ts +186 -0
- package/template/src/components/core/nc-alert.ts +153 -0
- package/template/src/components/core/nc-animation.ts +1150 -0
- package/template/src/components/core/nc-autocomplete.ts +271 -0
- package/template/src/components/core/nc-avatar-group.ts +113 -0
- package/template/src/components/core/nc-avatar.ts +148 -0
- package/template/src/components/core/nc-badge.ts +86 -0
- package/template/src/components/core/nc-bottom-nav.ts +214 -0
- package/template/src/components/core/nc-breadcrumb.ts +96 -0
- package/template/src/components/core/nc-button.ts +307 -0
- package/template/src/components/core/nc-card.ts +160 -0
- package/template/src/components/core/nc-checkbox.ts +282 -0
- package/template/src/components/core/nc-chip.ts +115 -0
- package/template/src/components/core/nc-code.ts +314 -0
- package/template/src/components/core/nc-collapsible.ts +154 -0
- package/template/src/components/core/nc-color-picker.ts +268 -0
- package/template/src/components/core/nc-copy-button.ts +119 -0
- package/template/src/components/core/nc-date-picker.ts +443 -0
- package/template/src/components/core/nc-div.ts +280 -0
- package/template/src/components/core/nc-divider.ts +81 -0
- package/template/src/components/core/nc-drawer.ts +230 -0
- package/template/src/components/core/nc-dropdown.ts +178 -0
- package/template/src/components/core/nc-empty-state.ts +134 -0
- package/template/src/components/core/nc-file-upload.ts +354 -0
- package/template/src/components/core/nc-form.ts +312 -0
- package/template/src/components/core/nc-image.ts +184 -0
- package/template/src/components/core/nc-input.ts +383 -0
- package/template/src/components/core/nc-kbd.ts +48 -0
- package/template/src/components/core/nc-menu-item.ts +193 -0
- package/template/src/components/core/nc-menu.ts +376 -0
- package/template/src/components/core/nc-modal.ts +238 -0
- package/template/src/components/core/nc-nav-item.ts +151 -0
- package/template/src/components/core/nc-number-input.ts +350 -0
- package/template/src/components/core/nc-otp-input.ts +235 -0
- package/template/src/components/core/nc-pagination.ts +178 -0
- package/template/src/components/core/nc-popover.ts +260 -0
- package/template/src/components/core/nc-progress-circular.ts +119 -0
- package/template/src/components/core/nc-progress.ts +134 -0
- package/template/src/components/core/nc-radio.ts +235 -0
- package/template/src/components/core/nc-rating.ts +266 -0
- package/template/src/components/core/nc-rich-text.ts +283 -0
- package/template/src/components/core/nc-scroll-top.ts +116 -0
- package/template/src/components/core/nc-select.ts +452 -0
- package/template/src/components/core/nc-skeleton.ts +107 -0
- package/template/src/components/core/nc-slider.ts +285 -0
- package/template/src/components/core/nc-snackbar.ts +230 -0
- package/template/src/components/core/nc-splash.ts +343 -0
- package/template/src/components/core/nc-stepper.ts +247 -0
- package/template/src/components/core/nc-switch.ts +281 -0
- package/template/src/components/core/nc-tab-item.ts +138 -0
- package/template/src/components/core/nc-table.ts +279 -0
- package/template/src/components/core/nc-tabs.ts +554 -0
- package/template/src/components/core/nc-tag-input.ts +279 -0
- package/template/src/components/core/nc-textarea.ts +216 -0
- package/template/src/components/core/nc-time-picker.ts +438 -0
- package/template/src/components/core/nc-timeline.ts +186 -0
- package/template/src/components/core/nc-tooltip.ts +143 -0
- package/template/src/components/frameworkRegistry.ts +68 -0
- package/template/src/components/preloadRegistry.ts +28 -0
- package/template/src/components/registry.ts +8 -0
- package/template/src/components/ui/dashboard-signal-lab.ts +284 -0
- package/template/src/constants/apiEndpoints.ts +27 -0
- package/template/src/constants/errorMessages.ts +23 -0
- package/template/src/constants/index.ts +8 -0
- package/template/src/constants/routePaths.ts +15 -0
- package/template/src/constants/storageKeys.ts +18 -0
- package/template/src/controllers/dashboard.controller.ts +200 -0
- package/template/src/controllers/home.controller.ts +21 -0
- package/template/src/controllers/index.ts +11 -0
- package/template/src/controllers/login.controller.ts +131 -0
- package/template/src/core/component.ts +354 -0
- package/template/src/core/errorHandler.ts +85 -0
- package/template/src/core/gpu-animation.ts +604 -0
- package/template/src/core/http.ts +173 -0
- package/template/src/core/lazyComponents.ts +90 -0
- package/template/src/core/router.ts +642 -0
- package/template/src/core/signals.ts +146 -0
- package/template/src/core/state.ts +248 -0
- package/template/src/dev/component-editor.ts +1363 -0
- package/template/src/dev/component-overlay.ts +278 -0
- package/template/src/dev/context-menu.ts +223 -0
- package/template/src/dev/denc-tools.ts +250 -0
- package/template/src/dev/hmr.ts +189 -0
- package/template/src/dev/nfbs.code-workspace +27 -0
- package/template/src/dev/outline-panel.ts +1247 -0
- package/template/src/middleware/auth.middleware.ts +23 -0
- package/template/src/routes/routes.ts +38 -0
- package/template/src/services/api.service.ts +394 -0
- package/template/src/services/auth.service.ts +176 -0
- package/template/src/services/index.ts +8 -0
- package/template/src/services/logger.service.ts +74 -0
- package/template/src/services/storage.service.ts +88 -0
- package/template/src/stores/appStore.ts +57 -0
- package/template/src/stores/uiStore.ts +36 -0
- package/template/src/styles/core-variables.css +219 -0
- package/template/src/styles/core.css +710 -0
- package/template/src/styles/main.css +3164 -0
- package/template/src/styles/variables.css +152 -0
- package/template/src/types/global.d.ts +47 -0
- package/template/src/utils/cacheBuster.ts +20 -0
- package/template/src/utils/dom.ts +149 -0
- package/template/src/utils/events.ts +203 -0
- package/template/src/utils/form.ts +176 -0
- package/template/src/utils/formatters.ts +169 -0
- package/template/src/utils/helpers.ts +195 -0
- package/template/src/utils/markdown.ts +307 -0
- package/template/src/utils/sidebar.ts +96 -0
- package/template/src/utils/smoothScroll.ts +85 -0
- package/template/src/utils/templates.ts +23 -0
- package/template/src/utils/validation.ts +73 -0
- package/template/src/views/protected/dashboard.html +293 -0
- package/template/src/views/public/home.html +150 -0
- package/template/src/views/public/login.html +102 -0
- package/template/tests/unit/component.test.ts +87 -0
- package/template/tests/unit/computed.test.ts +79 -0
- package/template/tests/unit/form.test.ts +68 -0
- package/template/tests/unit/formatters.test.ts +49 -0
- package/template/tests/unit/lazy-components.test.ts +59 -0
- package/template/tests/unit/markdown.test.ts +62 -0
- package/template/tests/unit/router.test.ts +112 -0
- package/template/tests/unit/signals.test.ts +54 -0
- package/template/tests/unit/validation.test.ts +50 -0
- package/template/tsconfig.build.json +21 -0
- package/template/tsconfig.json +51 -0
- package/template/vitest.config.ts +36 -0
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NcAnimation Component
|
|
3
|
+
*
|
|
4
|
+
* A declarative animation wrapper that intelligently selects the most
|
|
5
|
+
* GPU-efficient execution path for each animation type:
|
|
6
|
+
*
|
|
7
|
+
* - Simple CSS keyframes → runs entirely on the compositor thread (no JS)
|
|
8
|
+
* - Enter / exit → Web Animations API via gpu-animation.ts
|
|
9
|
+
* - Scroll-reveal → IntersectionObserver + Web Animations API
|
|
10
|
+
* - Continuous / looping → CSS animation (compositor-only)
|
|
11
|
+
* - Particle effects → WebGL (vertex shader) with canvas2d fallback
|
|
12
|
+
*
|
|
13
|
+
* Attributes:
|
|
14
|
+
* name — Animation preset name (see list below). Required.
|
|
15
|
+
* trigger — When to run: 'mount'|'visible'|'hover'|'click'|'manual' (default: 'mount')
|
|
16
|
+
* duration — Ms integer (default: varies per preset)
|
|
17
|
+
* delay — Ms delay before animation starts (default: 0)
|
|
18
|
+
* easing — CSS easing / cubic-bezier string (default: preset-specific)
|
|
19
|
+
* iterations — Number or 'infinite' (default: 1)
|
|
20
|
+
* distance — Slide/offset distance in px (default: 40)
|
|
21
|
+
* threshold — IntersectionObserver threshold 0-1 (default: 0.15, trigger=visible only)
|
|
22
|
+
* fill — Web Animations fill mode: 'forwards'|'backwards'|'both'|'none' (default: 'forwards')
|
|
23
|
+
* reverse — boolean — play animation backwards
|
|
24
|
+
* no-gpu-hint — boolean — skip will-change + contain hints (use for very tiny elements)
|
|
25
|
+
*
|
|
26
|
+
* Particle-specific attributes (all optional):
|
|
27
|
+
* origin-x — Horizontal start position as 0-1 fraction of screen width (default: 0.5 = center)
|
|
28
|
+
* origin-y — Vertical start position as 0-1 fraction of screen height (default: 0.5)
|
|
29
|
+
* Special shortcuts: 'top'=0, 'bottom'=1, 'left'=0, 'right'=1, 'center'=0.5
|
|
30
|
+
* target-x — End-point X fraction (used by 'converge'/'ripple' presets)
|
|
31
|
+
* target-y — End-point Y fraction
|
|
32
|
+
* count — Override particle count (integer)
|
|
33
|
+
* spread — Override spread/speed scale 0-2 (default: 1)
|
|
34
|
+
*
|
|
35
|
+
* Animation preset names:
|
|
36
|
+
* Enter/exit: fade-in | fade-out | slide-up | slide-down | slide-left | slide-right
|
|
37
|
+
* scale-in | scale-out | zoom-in | zoom-out | flip-x | flip-y
|
|
38
|
+
* Attention: pulse | shake | bounce | rubber-band | swing | jello | tada | heartbeat
|
|
39
|
+
* Continuous: spin | ping | float | glow
|
|
40
|
+
* Particles: confetti | sparkles | bubbles | snow | firework |
|
|
41
|
+
* electricity | fire | explosion | ripple
|
|
42
|
+
*
|
|
43
|
+
* Events:
|
|
44
|
+
* start — CustomEvent — animation begins
|
|
45
|
+
* finish — CustomEvent — animation ends (not fired for infinite)
|
|
46
|
+
* cancel — CustomEvent — animation was cancelled
|
|
47
|
+
*
|
|
48
|
+
* Methods (call on the element):
|
|
49
|
+
* el.play() — play / replay
|
|
50
|
+
* el.pause() — pause (Web Animations API animations only)
|
|
51
|
+
* el.cancel() — cancel and reset
|
|
52
|
+
*
|
|
53
|
+
* Slots:
|
|
54
|
+
* default — the content to animate
|
|
55
|
+
*
|
|
56
|
+
* Usage:
|
|
57
|
+
* <nc-animation name="fade-in" trigger="visible">
|
|
58
|
+
* <nc-card>...</nc-card>
|
|
59
|
+
* </nc-animation>
|
|
60
|
+
*
|
|
61
|
+
* <nc-animation name="slide-up" trigger="visible" delay="150">
|
|
62
|
+
* <p>Staggered paragraph</p>
|
|
63
|
+
* </nc-animation>
|
|
64
|
+
*
|
|
65
|
+
* <nc-animation name="pulse" trigger="hover" iterations="infinite">
|
|
66
|
+
* <nc-button>Hover me</nc-button>
|
|
67
|
+
* </nc-animation>
|
|
68
|
+
*
|
|
69
|
+
* <nc-animation name="confetti" trigger="click">
|
|
70
|
+
* <nc-button variant="success">Celebrate</nc-button>
|
|
71
|
+
* </nc-animation>
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { Component, defineComponent } from '@core/component.js';
|
|
75
|
+
import {
|
|
76
|
+
animate,
|
|
77
|
+
prepareForAnimation,
|
|
78
|
+
cleanupAnimation,
|
|
79
|
+
fadeIn,
|
|
80
|
+
fadeOut,
|
|
81
|
+
slideIn,
|
|
82
|
+
scaleIn,
|
|
83
|
+
createAnimationLoop,
|
|
84
|
+
createWebGLParticleSystem,
|
|
85
|
+
type AnimationOptions,
|
|
86
|
+
type ParticleConfig,
|
|
87
|
+
} from '@core/gpu-animation.js';
|
|
88
|
+
|
|
89
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
type AnimationTrigger = 'mount' | 'visible' | 'hover' | 'click' | 'manual';
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Defines whether an animation runs via:
|
|
95
|
+
* 'css' — a CSS class applied to the slotted element (compositor-only, zero JS)
|
|
96
|
+
* 'waapi' — Web Animations API via gpu-animation helpers (JS, GPU transform/opacity)
|
|
97
|
+
* 'particle'— WebGL/canvas particle system overlay
|
|
98
|
+
*/
|
|
99
|
+
type AnimationPath = 'css' | 'waapi' | 'particle';
|
|
100
|
+
|
|
101
|
+
interface PresetDef {
|
|
102
|
+
path: AnimationPath;
|
|
103
|
+
/** Default duration ms */
|
|
104
|
+
duration: number;
|
|
105
|
+
/** For 'waapi' path: function that runs the animation */
|
|
106
|
+
run?: (el: HTMLElement, opts: AnimationOptions & { distance?: number }) => Promise<void>;
|
|
107
|
+
/** For 'css' path: keyframes injected once into shadow DOM <style> */
|
|
108
|
+
keyframes?: string;
|
|
109
|
+
/** For 'particle' path: config */
|
|
110
|
+
particle?: ParticleConfig;
|
|
111
|
+
easing?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── Preset registry ───────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
const PRESETS: Record<string, PresetDef> = {
|
|
117
|
+
// ── Enter / exit (WAAPI — GPU transform + opacity) ──────────────────────
|
|
118
|
+
'fade-in': {
|
|
119
|
+
path: 'waapi', duration: 400,
|
|
120
|
+
run: (el, opts) => fadeIn(el, opts.duration),
|
|
121
|
+
},
|
|
122
|
+
'fade-out': {
|
|
123
|
+
path: 'waapi', duration: 400,
|
|
124
|
+
run: (el, opts) => fadeOut(el, opts.duration),
|
|
125
|
+
},
|
|
126
|
+
'slide-up': {
|
|
127
|
+
path: 'waapi', duration: 450,
|
|
128
|
+
run: (el, opts) => slideIn(el, 'up', opts.distance, opts.duration),
|
|
129
|
+
},
|
|
130
|
+
'slide-down': {
|
|
131
|
+
path: 'waapi', duration: 450,
|
|
132
|
+
run: (el, opts) => slideIn(el, 'down', opts.distance, opts.duration),
|
|
133
|
+
},
|
|
134
|
+
'slide-left': {
|
|
135
|
+
path: 'waapi', duration: 450,
|
|
136
|
+
run: (el, opts) => slideIn(el, 'left', opts.distance, opts.duration),
|
|
137
|
+
},
|
|
138
|
+
'slide-right': {
|
|
139
|
+
path: 'waapi', duration: 450,
|
|
140
|
+
run: (el, opts) => slideIn(el, 'right', opts.distance, opts.duration),
|
|
141
|
+
},
|
|
142
|
+
'scale-in': {
|
|
143
|
+
path: 'waapi', duration: 350,
|
|
144
|
+
run: (el, opts) => scaleIn(el, opts.duration),
|
|
145
|
+
},
|
|
146
|
+
'scale-out': {
|
|
147
|
+
path: 'waapi', duration: 350,
|
|
148
|
+
run: (el, opts) => animate(el, [
|
|
149
|
+
{ transform: 'scale3d(1,1,1)', opacity: '1' },
|
|
150
|
+
{ transform: 'scale3d(0.8,0.8,1)', opacity: '0' },
|
|
151
|
+
], opts),
|
|
152
|
+
},
|
|
153
|
+
'zoom-in': {
|
|
154
|
+
path: 'waapi', duration: 400,
|
|
155
|
+
run: (el, opts) => animate(el, [
|
|
156
|
+
{ transform: 'scale3d(0.5,0.5,1)', opacity: '0' },
|
|
157
|
+
{ transform: 'scale3d(1,1,1)', opacity: '1' },
|
|
158
|
+
], { ...opts, easing: 'cubic-bezier(0.34,1.56,0.64,1)' }),
|
|
159
|
+
},
|
|
160
|
+
'zoom-out': {
|
|
161
|
+
path: 'waapi', duration: 400,
|
|
162
|
+
run: (el, opts) => animate(el, [
|
|
163
|
+
{ transform: 'scale3d(1,1,1)', opacity: '1' },
|
|
164
|
+
{ transform: 'scale3d(0.5,0.5,1)', opacity: '0' },
|
|
165
|
+
], opts),
|
|
166
|
+
},
|
|
167
|
+
'flip-x': {
|
|
168
|
+
path: 'waapi', duration: 500,
|
|
169
|
+
run: (el, opts) => {
|
|
170
|
+
prepareForAnimation(el, ['transform', 'opacity']);
|
|
171
|
+
return animate(el, [
|
|
172
|
+
{ transform: 'perspective(400px) rotateX(90deg)', opacity: '0' },
|
|
173
|
+
{ transform: 'perspective(400px) rotateX(-10deg)', opacity: '1', offset: 0.6 },
|
|
174
|
+
{ transform: 'perspective(400px) rotateX(0deg)', opacity: '1' },
|
|
175
|
+
], opts);
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
'flip-y': {
|
|
179
|
+
path: 'waapi', duration: 500,
|
|
180
|
+
run: (el, opts) => {
|
|
181
|
+
prepareForAnimation(el, ['transform', 'opacity']);
|
|
182
|
+
return animate(el, [
|
|
183
|
+
{ transform: 'perspective(400px) rotateY(90deg)', opacity: '0' },
|
|
184
|
+
{ transform: 'perspective(400px) rotateY(-10deg)', opacity: '1', offset: 0.6 },
|
|
185
|
+
{ transform: 'perspective(400px) rotateY(0deg)', opacity: '1' },
|
|
186
|
+
], opts);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
// ── Attention seekers (WAAPI — brief GPU motion) ─────────────────────────
|
|
191
|
+
'pulse': {
|
|
192
|
+
path: 'waapi', duration: 600,
|
|
193
|
+
run: (el, opts) => {
|
|
194
|
+
prepareForAnimation(el, ['transform']);
|
|
195
|
+
return animate(el, [
|
|
196
|
+
{ transform: 'scale3d(1,1,1)' },
|
|
197
|
+
{ transform: 'scale3d(1.05,1.05,1)', offset: 0.5 },
|
|
198
|
+
{ transform: 'scale3d(1,1,1)' },
|
|
199
|
+
], opts);
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
'shake': {
|
|
203
|
+
path: 'waapi', duration: 500,
|
|
204
|
+
run: (el, opts) => {
|
|
205
|
+
prepareForAnimation(el, ['transform']);
|
|
206
|
+
return animate(el, [
|
|
207
|
+
{ transform: 'translate3d(0,0,0)' },
|
|
208
|
+
{ transform: 'translate3d(-8px,0,0)', offset: 0.1 },
|
|
209
|
+
{ transform: 'translate3d(8px,0,0)', offset: 0.3 },
|
|
210
|
+
{ transform: 'translate3d(-8px,0,0)', offset: 0.5 },
|
|
211
|
+
{ transform: 'translate3d(8px,0,0)', offset: 0.7 },
|
|
212
|
+
{ transform: 'translate3d(-4px,0,0)', offset: 0.9 },
|
|
213
|
+
{ transform: 'translate3d(0,0,0)' },
|
|
214
|
+
], { ...opts, easing: 'ease-in-out' });
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
'bounce': {
|
|
218
|
+
path: 'waapi', duration: 800,
|
|
219
|
+
run: (el, opts) => {
|
|
220
|
+
prepareForAnimation(el, ['transform']);
|
|
221
|
+
return animate(el, [
|
|
222
|
+
{ transform: 'translate3d(0,0,0)', animationTimingFunction: 'cubic-bezier(0.8,0,1,1)' },
|
|
223
|
+
{ transform: 'translate3d(0,-30px,0)', offset: 0.4, animationTimingFunction: 'cubic-bezier(0,0,0.2,1)' },
|
|
224
|
+
{ transform: 'translate3d(0,0,0)', offset: 0.6, animationTimingFunction: 'cubic-bezier(0.8,0,1,1)' },
|
|
225
|
+
{ transform: 'translate3d(0,-15px,0)', offset: 0.8, animationTimingFunction: 'cubic-bezier(0,0,0.2,1)' },
|
|
226
|
+
{ transform: 'translate3d(0,0,0)' },
|
|
227
|
+
], { ...opts, easing: 'linear' });
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
'rubber-band': {
|
|
231
|
+
path: 'waapi', duration: 700,
|
|
232
|
+
run: (el, opts) => {
|
|
233
|
+
prepareForAnimation(el, ['transform']);
|
|
234
|
+
return animate(el, [
|
|
235
|
+
{ transform: 'scale3d(1,1,1)' },
|
|
236
|
+
{ transform: 'scale3d(1.25,0.75,1)', offset: 0.3 },
|
|
237
|
+
{ transform: 'scale3d(0.75,1.25,1)', offset: 0.5 },
|
|
238
|
+
{ transform: 'scale3d(1.15,0.85,1)', offset: 0.65 },
|
|
239
|
+
{ transform: 'scale3d(0.95,1.05,1)', offset: 0.75 },
|
|
240
|
+
{ transform: 'scale3d(1.05,0.95,1)', offset: 0.9 },
|
|
241
|
+
{ transform: 'scale3d(1,1,1)' },
|
|
242
|
+
], { ...opts, easing: 'ease-in-out' });
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
'swing': {
|
|
246
|
+
path: 'waapi', duration: 700,
|
|
247
|
+
run: (el, opts) => {
|
|
248
|
+
prepareForAnimation(el, ['transform']);
|
|
249
|
+
return animate(el, [
|
|
250
|
+
{ transform: 'rotate3d(0,0,1,0deg)' },
|
|
251
|
+
{ transform: 'rotate3d(0,0,1,15deg)', offset: 0.2 },
|
|
252
|
+
{ transform: 'rotate3d(0,0,1,-10deg)', offset: 0.4 },
|
|
253
|
+
{ transform: 'rotate3d(0,0,1,5deg)', offset: 0.6 },
|
|
254
|
+
{ transform: 'rotate3d(0,0,1,-5deg)', offset: 0.8 },
|
|
255
|
+
{ transform: 'rotate3d(0,0,1,0deg)' },
|
|
256
|
+
], { ...opts, easing: 'ease-in-out' });
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
'jello': {
|
|
260
|
+
path: 'waapi', duration: 900,
|
|
261
|
+
run: (el, opts) => {
|
|
262
|
+
prepareForAnimation(el, ['transform']);
|
|
263
|
+
return animate(el, [
|
|
264
|
+
{ transform: 'skewX(0) skewY(0)' },
|
|
265
|
+
{ transform: 'skewX(-12.5deg) skewY(-12.5deg)', offset: 0.11 },
|
|
266
|
+
{ transform: 'skewX(6.25deg) skewY(6.25deg)', offset: 0.22 },
|
|
267
|
+
{ transform: 'skewX(-3.125deg) skewY(-3.125deg)', offset: 0.33 },
|
|
268
|
+
{ transform: 'skewX(1.5625deg) skewY(1.5625deg)', offset: 0.44 },
|
|
269
|
+
{ transform: 'skewX(-0.78125deg) skewY(-0.78125deg)', offset: 0.55 },
|
|
270
|
+
{ transform: 'skewX(0.390625deg) skewY(0.390625deg)', offset: 0.66 },
|
|
271
|
+
{ transform: 'skewX(-0.1953125deg) skewY(-0.1953125deg)', offset: 0.77 },
|
|
272
|
+
{ transform: 'skewX(0) skewY(0)' },
|
|
273
|
+
], { ...opts, easing: 'ease-in-out' });
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
'tada': {
|
|
277
|
+
path: 'waapi', duration: 800,
|
|
278
|
+
run: (el, opts) => {
|
|
279
|
+
prepareForAnimation(el, ['transform']);
|
|
280
|
+
return animate(el, [
|
|
281
|
+
{ transform: 'scale3d(1,1,1) rotate3d(0,0,1,0deg)' },
|
|
282
|
+
{ transform: 'scale3d(0.9,0.9,0.9) rotate3d(0,0,1,-3deg)', offset: 0.1 },
|
|
283
|
+
{ transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,3deg)', offset: 0.3 },
|
|
284
|
+
{ transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,-3deg)', offset: 0.5 },
|
|
285
|
+
{ transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,3deg)', offset: 0.7 },
|
|
286
|
+
{ transform: 'scale3d(1.1,1.1,1.1) rotate3d(0,0,1,-3deg)', offset: 0.8 },
|
|
287
|
+
{ transform: 'scale3d(1,1,1) rotate3d(0,0,1,0deg)' },
|
|
288
|
+
], { ...opts, easing: 'ease-in-out' });
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
'heartbeat': {
|
|
292
|
+
path: 'waapi', duration: 600,
|
|
293
|
+
run: (el, opts) => {
|
|
294
|
+
prepareForAnimation(el, ['transform']);
|
|
295
|
+
return animate(el, [
|
|
296
|
+
{ transform: 'scale3d(1,1,1)' },
|
|
297
|
+
{ transform: 'scale3d(1.15,1.15,1)', offset: 0.14 },
|
|
298
|
+
{ transform: 'scale3d(1,1,1)', offset: 0.28 },
|
|
299
|
+
{ transform: 'scale3d(1.15,1.15,1)', offset: 0.42 },
|
|
300
|
+
{ transform: 'scale3d(1,1,1)' },
|
|
301
|
+
], { ...opts, easing: 'ease-in-out' });
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
// ── Continuous / looping (CSS — compositor-only, zero rAF cost) ──────────
|
|
306
|
+
'spin': {
|
|
307
|
+
path: 'css', duration: 1000,
|
|
308
|
+
keyframes: `from { transform: rotate(0deg); } to { transform: rotate(360deg); }`,
|
|
309
|
+
},
|
|
310
|
+
'ping': {
|
|
311
|
+
path: 'css', duration: 1000,
|
|
312
|
+
keyframes: `
|
|
313
|
+
0% { transform: scale3d(1,1,1); opacity: 1; }
|
|
314
|
+
75%, 100% { transform: scale3d(2,2,1); opacity: 0; }
|
|
315
|
+
`,
|
|
316
|
+
},
|
|
317
|
+
'float': {
|
|
318
|
+
path: 'css', duration: 3000,
|
|
319
|
+
easing: 'ease-in-out',
|
|
320
|
+
keyframes: `
|
|
321
|
+
0%, 100% { transform: translate3d(0,0,0); }
|
|
322
|
+
50% { transform: translate3d(0,-12px,0); }
|
|
323
|
+
`,
|
|
324
|
+
},
|
|
325
|
+
'glow': {
|
|
326
|
+
path: 'css', duration: 2000,
|
|
327
|
+
easing: 'ease-in-out',
|
|
328
|
+
keyframes: `
|
|
329
|
+
0%, 100% { filter: brightness(1) drop-shadow(0 0 0px currentColor); }
|
|
330
|
+
50% { filter: brightness(1.2) drop-shadow(0 0 8px currentColor); }
|
|
331
|
+
`,
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
// ── Particle system (WebGL → canvas2d fallback) ───────────────────────────
|
|
335
|
+
'confetti': {
|
|
336
|
+
path: 'particle', duration: 3000,
|
|
337
|
+
particle: {
|
|
338
|
+
count: 120,
|
|
339
|
+
colors: ['#f43f5e','#f59e0b','#10b981','#3b82f6','#8b5cf6','#ec4899'],
|
|
340
|
+
size: { min: 4, max: 10 },
|
|
341
|
+
speed: { min: 60, max: 160 },
|
|
342
|
+
type: 'shower',
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
'sparkles': {
|
|
346
|
+
path: 'particle', duration: 2000,
|
|
347
|
+
particle: {
|
|
348
|
+
count: 60,
|
|
349
|
+
colors: ['#fcd34d','#fbbf24','#fde68a','#fff'],
|
|
350
|
+
size: { min: 2, max: 6 },
|
|
351
|
+
speed: { min: 40, max: 100 },
|
|
352
|
+
type: 'burst',
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
'bubbles': {
|
|
356
|
+
path: 'particle', duration: 4000,
|
|
357
|
+
particle: {
|
|
358
|
+
count: 50,
|
|
359
|
+
colors: ['#93c5fd','#bfdbfe','#dbeafe'],
|
|
360
|
+
size: { min: 6, max: 18 },
|
|
361
|
+
speed: { min: 20, max: 60 },
|
|
362
|
+
type: 'float',
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
'snow': {
|
|
366
|
+
path: 'particle', duration: 5000,
|
|
367
|
+
particle: {
|
|
368
|
+
count: 80,
|
|
369
|
+
colors: ['#e0f2fe','#bae6fd','#fff'],
|
|
370
|
+
size: { min: 3, max: 8 },
|
|
371
|
+
speed: { min: 20, max: 50 },
|
|
372
|
+
type: 'shower',
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
'firework': {
|
|
376
|
+
path: 'particle', duration: 3000,
|
|
377
|
+
particle: {
|
|
378
|
+
count: 120,
|
|
379
|
+
colors: ['#f43f5e','#f59e0b','#fcd34d','#34d399','#60a5fa','#c084fc','#fff'],
|
|
380
|
+
size: { min: 2, max: 5 },
|
|
381
|
+
speed: { min: 280, max: 460 },
|
|
382
|
+
type: 'firework',
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
// ── New presets ───────────────────────────────────────────────────────────
|
|
386
|
+
'electricity': {
|
|
387
|
+
path: 'particle', duration: 2000,
|
|
388
|
+
particle: {
|
|
389
|
+
count: 5, // number of simultaneous bolts
|
|
390
|
+
colors: ['#a5f3fc','#e0f2fe','#7dd3fc','#ffffff'],
|
|
391
|
+
size: { min: 1, max: 2 },
|
|
392
|
+
speed: { min: 0, max: 0 },
|
|
393
|
+
type: 'electricity' as ParticleConfig['type'],
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
'fire': {
|
|
397
|
+
path: 'particle', duration: 3000,
|
|
398
|
+
particle: {
|
|
399
|
+
count: 120,
|
|
400
|
+
colors: ['#ef4444','#f97316','#fbbf24','#fde68a'],
|
|
401
|
+
size: { min: 8, max: 22 },
|
|
402
|
+
speed: { min: 80, max: 180 },
|
|
403
|
+
type: 'fire' as ParticleConfig['type'],
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
'explosion': {
|
|
407
|
+
path: 'particle', duration: 2000,
|
|
408
|
+
particle: {
|
|
409
|
+
count: 110,
|
|
410
|
+
colors: ['#ef4444','#f97316','#fbbf24','#fde68a','#fff'],
|
|
411
|
+
size: { min: 4, max: 16 },
|
|
412
|
+
speed: { min: 90, max: 360 },
|
|
413
|
+
type: 'explosion',
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
'ripple': {
|
|
417
|
+
path: 'particle', duration: 2500,
|
|
418
|
+
particle: {
|
|
419
|
+
count: 5, // number of concentric rings
|
|
420
|
+
colors: ['#3b82f6','#60a5fa','#93c5fd','#bfdbfe'],
|
|
421
|
+
size: { min: 2, max: 4 },
|
|
422
|
+
speed: { min: 0, max: 0 },
|
|
423
|
+
type: 'ripple' as ParticleConfig['type'],
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// ── Component ─────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
// Global set — keyframes injected into document.head, deduplicated across all instances
|
|
431
|
+
const _injectedKeyframes = new Set<string>();
|
|
432
|
+
|
|
433
|
+
export class NcAnimation extends Component {
|
|
434
|
+
static useShadowDOM = true;
|
|
435
|
+
|
|
436
|
+
static get observedAttributes() {
|
|
437
|
+
return ['name', 'trigger', 'duration', 'delay', 'easing', 'iterations',
|
|
438
|
+
'distance', 'threshold', 'fill', 'reverse', 'no-gpu-hint',
|
|
439
|
+
'origin-x', 'origin-y', 'target-x', 'target-y', 'count', 'spread'];
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// active Web Animation handle — lets us pause / cancel
|
|
443
|
+
private _waAnimation: Animation | null = null;
|
|
444
|
+
// particle loop + webgl handles
|
|
445
|
+
private _particleLoop: ReturnType<typeof createAnimationLoop> | null = null;
|
|
446
|
+
private _particleWebGL: ReturnType<typeof createWebGLParticleSystem> | null = null;
|
|
447
|
+
private _canvas: HTMLCanvasElement | null = null;
|
|
448
|
+
// IntersectionObserver for trigger=visible
|
|
449
|
+
private _io: IntersectionObserver | null = null;
|
|
450
|
+
// tear-down refs
|
|
451
|
+
private _hoverOff: (() => void) | null = null;
|
|
452
|
+
private _clickOff: (() => void) | null = null;
|
|
453
|
+
// css animation class injected into shadow
|
|
454
|
+
private _cssAnimName = '';
|
|
455
|
+
// track whether IntersectionObserver already fired
|
|
456
|
+
private _visibleFired = false;
|
|
457
|
+
|
|
458
|
+
template() {
|
|
459
|
+
return `
|
|
460
|
+
<style>
|
|
461
|
+
:host {
|
|
462
|
+
display: contents;
|
|
463
|
+
}
|
|
464
|
+
.wrap {
|
|
465
|
+
display: contents;
|
|
466
|
+
}
|
|
467
|
+
.canvas-layer {
|
|
468
|
+
position: absolute;
|
|
469
|
+
inset: 0;
|
|
470
|
+
pointer-events: none;
|
|
471
|
+
z-index: 10;
|
|
472
|
+
}
|
|
473
|
+
/* Will be extended dynamically per CSS preset */
|
|
474
|
+
</style>
|
|
475
|
+
<div class="wrap"><slot></slot></div>
|
|
476
|
+
`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
onMount() {
|
|
480
|
+
this._setup();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
onUnmount() {
|
|
484
|
+
this._teardown();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// ── Public methods ────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
play() { this._run(); }
|
|
490
|
+
|
|
491
|
+
pause() {
|
|
492
|
+
if (this._waAnimation && this._waAnimation.playState === 'running') {
|
|
493
|
+
this._waAnimation.pause();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
cancel() {
|
|
498
|
+
this._waAnimation?.cancel();
|
|
499
|
+
this._waAnimation = null;
|
|
500
|
+
this._stopParticles();
|
|
501
|
+
const target = this._target();
|
|
502
|
+
if (target) {
|
|
503
|
+
cleanupAnimation(target);
|
|
504
|
+
target.style.cssText = target.style.cssText.replace(/animation[^;]*;?/g, '');
|
|
505
|
+
}
|
|
506
|
+
this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// ── Setup ─────────────────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
private _setup() {
|
|
512
|
+
const trigger = this._attr('trigger', 'mount') as AnimationTrigger;
|
|
513
|
+
|
|
514
|
+
switch (trigger) {
|
|
515
|
+
case 'mount':
|
|
516
|
+
this._scheduleRun();
|
|
517
|
+
break;
|
|
518
|
+
case 'visible':
|
|
519
|
+
this._setupVisibleTrigger();
|
|
520
|
+
break;
|
|
521
|
+
case 'hover':
|
|
522
|
+
this._setupHoverTrigger();
|
|
523
|
+
break;
|
|
524
|
+
case 'click':
|
|
525
|
+
this._setupClickTrigger();
|
|
526
|
+
break;
|
|
527
|
+
// 'manual' — nothing; caller calls .play()
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private _teardown() {
|
|
532
|
+
this._io?.disconnect();
|
|
533
|
+
this._io = null;
|
|
534
|
+
this._hoverOff?.();
|
|
535
|
+
this._hoverOff = null;
|
|
536
|
+
this._clickOff?.();
|
|
537
|
+
this._clickOff = null;
|
|
538
|
+
this._waAnimation?.cancel();
|
|
539
|
+
this._waAnimation = null;
|
|
540
|
+
this._stopParticles();
|
|
541
|
+
const target = this._target();
|
|
542
|
+
if (target) cleanupAnimation(target);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
private _scheduleRun() {
|
|
546
|
+
const delay = this._numAttr('delay', 0);
|
|
547
|
+
if (delay > 0) {
|
|
548
|
+
setTimeout(() => this._run(), delay);
|
|
549
|
+
} else {
|
|
550
|
+
// defer one tick so the slot is painted
|
|
551
|
+
requestAnimationFrame(() => this._run());
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private _setupVisibleTrigger() {
|
|
556
|
+
const threshold = parseFloat(this.getAttribute('threshold') || '0.15');
|
|
557
|
+
this._io = new IntersectionObserver((entries) => {
|
|
558
|
+
for (const entry of entries) {
|
|
559
|
+
if (entry.isIntersecting && !this._visibleFired) {
|
|
560
|
+
this._visibleFired = true;
|
|
561
|
+
this._scheduleRun();
|
|
562
|
+
this._io?.disconnect();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}, { threshold });
|
|
566
|
+
this._io.observe(this);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private _setupHoverTrigger() {
|
|
570
|
+
const onEnter = () => { this._visibleFired = false; this._run(); };
|
|
571
|
+
this.addEventListener('mouseenter', onEnter);
|
|
572
|
+
this._hoverOff = () => this.removeEventListener('mouseenter', onEnter);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
private _setupClickTrigger() {
|
|
576
|
+
const onClick = () => { this._visibleFired = false; this._run(); };
|
|
577
|
+
this.addEventListener('click', onClick);
|
|
578
|
+
this._clickOff = () => this.removeEventListener('click', onClick);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// ── Core run dispatcher ───────────────────────────────────────────────────
|
|
582
|
+
|
|
583
|
+
private _run() {
|
|
584
|
+
const name = this._attr('name', 'fade-in');
|
|
585
|
+
const preset = PRESETS[name];
|
|
586
|
+
if (!preset) {
|
|
587
|
+
console.warn(`[nc-animation] Unknown preset: "${name}"`);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
this.dispatchEvent(new CustomEvent('start', { bubbles: true, composed: true }));
|
|
592
|
+
|
|
593
|
+
switch (preset.path) {
|
|
594
|
+
case 'waapi': this._runWAAPI(preset); break;
|
|
595
|
+
case 'css': this._runCSS(preset, name); break;
|
|
596
|
+
case 'particle': this._runParticles(preset); break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── WAAPI path ────────────────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
private _runWAAPI(preset: PresetDef) {
|
|
603
|
+
const target = this._target();
|
|
604
|
+
if (!target || !preset.run) return;
|
|
605
|
+
|
|
606
|
+
const noHint = this.hasAttribute('no-gpu-hint');
|
|
607
|
+
if (!noHint) prepareForAnimation(target, ['transform', 'opacity']);
|
|
608
|
+
|
|
609
|
+
const opts: AnimationOptions & { distance?: number } = {
|
|
610
|
+
duration: this._numAttr('duration', preset.duration),
|
|
611
|
+
delay: this._numAttr('delay', 0),
|
|
612
|
+
easing: this.getAttribute('easing') ?? preset.easing ?? 'cubic-bezier(0.4,0,0.2,1)',
|
|
613
|
+
fill: (this.getAttribute('fill') as FillMode | null) ?? 'forwards',
|
|
614
|
+
iterations: this._iterAttr(),
|
|
615
|
+
distance: this._numAttr('distance', 40),
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// Capture the animation handle so pause/cancel work
|
|
619
|
+
// We monkey-patch by running and grabbing the animation from the element
|
|
620
|
+
const before = target.getAnimations().length;
|
|
621
|
+
preset.run(target, opts).then(() => {
|
|
622
|
+
if (!noHint && opts.iterations === 1) cleanupAnimation(target);
|
|
623
|
+
this.dispatchEvent(new CustomEvent('finish', { bubbles: true, composed: true }));
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// Grab the newest animation handle
|
|
627
|
+
requestAnimationFrame(() => {
|
|
628
|
+
const anims = target.getAnimations();
|
|
629
|
+
if (anims.length > before) {
|
|
630
|
+
this._waAnimation = anims[anims.length - 1];
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── CSS compositor path ───────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
private _runCSS(preset: PresetDef, name: string) {
|
|
638
|
+
const target = this._target();
|
|
639
|
+
if (!target || !preset.keyframes) return;
|
|
640
|
+
|
|
641
|
+
const animName = `nc-anim-${name}`;
|
|
642
|
+
|
|
643
|
+
// Inject keyframes into document.head (NOT shadow root) — slotted targets
|
|
644
|
+
// are light DOM elements; shadow-scoped @keyframes are invisible to them.
|
|
645
|
+
if (!_injectedKeyframes.has(animName)) {
|
|
646
|
+
const style = document.createElement('style');
|
|
647
|
+
style.id = `kf-${animName}`;
|
|
648
|
+
style.textContent = `@keyframes ${animName} { ${preset.keyframes} }`;
|
|
649
|
+
document.head.appendChild(style);
|
|
650
|
+
_injectedKeyframes.add(animName);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const duration = this._numAttr('duration', preset.duration);
|
|
654
|
+
const delay = this._numAttr('delay', 0);
|
|
655
|
+
const easing = this.getAttribute('easing') ?? preset.easing ?? 'ease-in-out';
|
|
656
|
+
const iterations = this.getAttribute('iterations') === 'infinite' ? 'infinite' : this._iterAttr();
|
|
657
|
+
const fill = this.getAttribute('fill') ?? (iterations === 'infinite' ? 'none' : 'forwards');
|
|
658
|
+
const direction = this.hasAttribute('reverse') ? 'reverse' : 'normal';
|
|
659
|
+
|
|
660
|
+
target.style.willChange = 'transform, opacity';
|
|
661
|
+
target.style.animation = `${animName} ${duration}ms ${easing} ${delay}ms ${iterations} ${fill} ${direction}`;
|
|
662
|
+
|
|
663
|
+
if (iterations !== 'infinite') {
|
|
664
|
+
target.addEventListener('animationend', () => {
|
|
665
|
+
cleanupAnimation(target);
|
|
666
|
+
this.dispatchEvent(new CustomEvent('finish', { bubbles: true, composed: true }));
|
|
667
|
+
}, { once: true });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── Particle path ─────────────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
/** Resolve 'top'|'bottom'|'left'|'right'|'center'|number-string → 0-1 float */
|
|
674
|
+
private _resolvePos(raw: string | null, fallback: number): number {
|
|
675
|
+
if (!raw) return fallback;
|
|
676
|
+
const aliases: Record<string, number> = { top: 0, bottom: 1, left: 0, right: 1, center: 0.5 };
|
|
677
|
+
if (raw in aliases) return aliases[raw];
|
|
678
|
+
const n = parseFloat(raw);
|
|
679
|
+
return isNaN(n) ? fallback : Math.max(0, Math.min(1, n));
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private _runParticles(preset: PresetDef) {
|
|
683
|
+
if (!preset.particle) return;
|
|
684
|
+
this._stopParticles();
|
|
685
|
+
|
|
686
|
+
// Full-viewport canvas — particles live in screen space, not element space
|
|
687
|
+
const canvas = document.createElement('canvas');
|
|
688
|
+
canvas.width = window.innerWidth;
|
|
689
|
+
canvas.height = window.innerHeight;
|
|
690
|
+
canvas.style.cssText =
|
|
691
|
+
'position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:9999;';
|
|
692
|
+
document.body.appendChild(canvas);
|
|
693
|
+
this._canvas = canvas;
|
|
694
|
+
|
|
695
|
+
// Resolve origin from attributes (default center of screen)
|
|
696
|
+
const ox = this._resolvePos(this.getAttribute('origin-x'), 0.5);
|
|
697
|
+
const oy = this._resolvePos(this.getAttribute('origin-y'), 0.5);
|
|
698
|
+
const tx = this._resolvePos(this.getAttribute('target-x'), 0.5);
|
|
699
|
+
const ty = this._resolvePos(this.getAttribute('target-y'), 0.5);
|
|
700
|
+
|
|
701
|
+
// Allow attribute overrides on count and spread
|
|
702
|
+
const countOverride = this.getAttribute('count');
|
|
703
|
+
const spreadScale = parseFloat(this.getAttribute('spread') ?? '1');
|
|
704
|
+
const cfg: ParticleConfig = {
|
|
705
|
+
...preset.particle,
|
|
706
|
+
count: countOverride ? parseInt(countOverride, 10) : preset.particle.count,
|
|
707
|
+
speed: preset.particle.speed
|
|
708
|
+
? { min: preset.particle.speed.min * spreadScale, max: preset.particle.speed.max * spreadScale }
|
|
709
|
+
: { min: 40, max: 120 },
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// Always use canvas2d — the WebGL system from gpu-animation doesn't
|
|
713
|
+
// support per-preset behaviors (electricity, fire, ripple etc.)
|
|
714
|
+
this._runCanvas2D(canvas, cfg, ox, oy, tx, ty, preset);
|
|
715
|
+
|
|
716
|
+
const duration = this._numAttr('duration', preset.duration);
|
|
717
|
+
setTimeout(() => {
|
|
718
|
+
this._stopParticles();
|
|
719
|
+
this.dispatchEvent(new CustomEvent('finish', { bubbles: true, composed: true }));
|
|
720
|
+
}, duration);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Full canvas2d particle engine.
|
|
725
|
+
* origin / target are 0-1 fractions of canvas dimensions.
|
|
726
|
+
* Each preset type gets its own spawn + update behaviour.
|
|
727
|
+
*/
|
|
728
|
+
private _runCanvas2D(
|
|
729
|
+
canvas: HTMLCanvasElement,
|
|
730
|
+
config: ParticleConfig,
|
|
731
|
+
ox: number, oy: number,
|
|
732
|
+
tx: number, ty: number,
|
|
733
|
+
preset: PresetDef,
|
|
734
|
+
) {
|
|
735
|
+
const ctx = canvas.getContext('2d');
|
|
736
|
+
if (!ctx) return;
|
|
737
|
+
|
|
738
|
+
const W = canvas.width;
|
|
739
|
+
const H = canvas.height;
|
|
740
|
+
const originX = ox * W;
|
|
741
|
+
const originY = oy * H;
|
|
742
|
+
const targetX = tx * W;
|
|
743
|
+
const targetY = ty * H;
|
|
744
|
+
|
|
745
|
+
const { count, colors = ['#667eea'], size = { min: 3, max: 8 }, speed = { min: 40, max: 120 }, type = 'burst' } = config;
|
|
746
|
+
const rnd = (min: number, max: number) => min + Math.random() * (max - min);
|
|
747
|
+
const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
|
|
748
|
+
const hex2rgb = (hex: string): [number,number,number] => {
|
|
749
|
+
const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
750
|
+
return r ? [parseInt(r[1],16), parseInt(r[2],16), parseInt(r[3],16)] : [255,255,255];
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// ── Particle data arrays (avoid GC pressure) ─────────────────────────
|
|
754
|
+
interface P {
|
|
755
|
+
x: number; y: number; vx: number; vy: number;
|
|
756
|
+
ax: number; ay: number; // acceleration
|
|
757
|
+
size: number; color: string;
|
|
758
|
+
alpha: number;
|
|
759
|
+
life: number; maxLife: number; // 0-1
|
|
760
|
+
rot?: number; rotV?: number; // rotation for rect particles
|
|
761
|
+
trail?: Array<[number,number]>; // for electricity
|
|
762
|
+
wave?: number; // phase offset for ripple
|
|
763
|
+
shape?: 'circle'|'rect'|'line'|'arc';
|
|
764
|
+
}
|
|
765
|
+
const particles: P[] = [];
|
|
766
|
+
|
|
767
|
+
// ── Spawn logic per type ─────────────────────────────────────────────
|
|
768
|
+
const spawnParticle = (i: number): P => {
|
|
769
|
+
const t = count > 1 ? i / (count - 1) : 0.5;
|
|
770
|
+
switch (type) {
|
|
771
|
+
case 'shower': {
|
|
772
|
+
// rain from origin-x spread across top edge
|
|
773
|
+
const spread = W * 0.6;
|
|
774
|
+
return {
|
|
775
|
+
x: originX + (Math.random() - 0.5) * spread,
|
|
776
|
+
y: originY * H < H * 0.3 ? -10 : originY * H,
|
|
777
|
+
vx: rnd(-30, 30),
|
|
778
|
+
vy: speed.min + Math.random() * (speed.max - speed.min),
|
|
779
|
+
ax: 0, ay: 60,
|
|
780
|
+
size: rnd(size.min, size.max),
|
|
781
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
782
|
+
rot: Math.random() * Math.PI * 2,
|
|
783
|
+
rotV: rnd(-3, 3),
|
|
784
|
+
shape: 'rect',
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
case 'burst': {
|
|
788
|
+
const angle = t * Math.PI * 2;
|
|
789
|
+
const spd = rnd(speed.min, speed.max);
|
|
790
|
+
return {
|
|
791
|
+
x: originX, y: originY,
|
|
792
|
+
vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd,
|
|
793
|
+
ax: 0, ay: 20,
|
|
794
|
+
size: rnd(size.min, size.max),
|
|
795
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
796
|
+
rot: 0, rotV: rnd(-5, 5), shape: 'circle',
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
case 'explode': {
|
|
800
|
+
const angle = Math.random() * Math.PI * 2;
|
|
801
|
+
const spd = rnd(speed.min, speed.max);
|
|
802
|
+
return {
|
|
803
|
+
x: originX, y: originY,
|
|
804
|
+
vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd,
|
|
805
|
+
ax: 0, ay: 120,
|
|
806
|
+
size: rnd(size.min, size.max),
|
|
807
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
808
|
+
rot: Math.random() * Math.PI * 2,
|
|
809
|
+
rotV: rnd(-8, 8), shape: 'rect',
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
case 'float': {
|
|
813
|
+
return {
|
|
814
|
+
x: originX + (Math.random() - 0.5) * W * 0.8,
|
|
815
|
+
y: H + rnd(0, H * 0.2),
|
|
816
|
+
vx: rnd(-20, 20),
|
|
817
|
+
vy: -(speed.min + Math.random() * (speed.max - speed.min)),
|
|
818
|
+
ax: Math.sin(i) * 5, ay: 0,
|
|
819
|
+
size: rnd(size.min, size.max),
|
|
820
|
+
color: pick(colors), alpha: 0.7, life: 1, maxLife: 1,
|
|
821
|
+
shape: 'circle',
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
case 'spiral': {
|
|
825
|
+
const a = t * Math.PI * 8;
|
|
826
|
+
const r = t * Math.min(W, H) * 0.4;
|
|
827
|
+
const spd = rnd(speed.min, speed.max);
|
|
828
|
+
return {
|
|
829
|
+
x: originX + Math.cos(a) * r,
|
|
830
|
+
y: originY + Math.sin(a) * r,
|
|
831
|
+
vx: Math.cos(a + Math.PI/2) * spd,
|
|
832
|
+
vy: Math.sin(a + Math.PI/2) * spd,
|
|
833
|
+
ax: 0, ay: 0,
|
|
834
|
+
size: rnd(size.min, size.max),
|
|
835
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
836
|
+
shape: 'circle',
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
case 'converge': {
|
|
840
|
+
const angle = Math.random() * Math.PI * 2;
|
|
841
|
+
const dist = rnd(Math.min(W,H)*0.3, Math.min(W,H)*0.6);
|
|
842
|
+
const sx = targetX + Math.cos(angle)*dist;
|
|
843
|
+
const sy = targetY + Math.sin(angle)*dist;
|
|
844
|
+
const spd = rnd(speed.min, speed.max);
|
|
845
|
+
const dx = targetX - sx, dy = targetY - sy;
|
|
846
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
847
|
+
return {
|
|
848
|
+
x: sx, y: sy,
|
|
849
|
+
vx: (dx/len)*spd, vy: (dy/len)*spd,
|
|
850
|
+
ax: 0, ay: 0,
|
|
851
|
+
size: rnd(size.min, size.max),
|
|
852
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
853
|
+
shape: 'circle',
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
case 'firework': {
|
|
857
|
+
// Particles burst from random cluster positions across upper screen
|
|
858
|
+
const clusterSize = 20;
|
|
859
|
+
const clusterIdx = Math.floor(i / clusterSize);
|
|
860
|
+
const totalClusters = Math.max(1, Math.ceil(count / clusterSize));
|
|
861
|
+
// Spread bursts evenly across horizontal, upper 55% of screen
|
|
862
|
+
const burstX = W * (0.1 + (clusterIdx / Math.max(totalClusters - 1, 1)) * 0.8);
|
|
863
|
+
const burstY = H * (0.1 + Math.random() * 0.35);
|
|
864
|
+
const angle = ((i % clusterSize) / clusterSize) * Math.PI * 2 + (Math.random() - 0.5) * 0.4;
|
|
865
|
+
const spd = rnd(speed.min, speed.max);
|
|
866
|
+
return {
|
|
867
|
+
x: burstX, y: burstY,
|
|
868
|
+
vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd,
|
|
869
|
+
ax: 0, ay: 80,
|
|
870
|
+
size: rnd(size.min, size.max),
|
|
871
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
872
|
+
shape: 'circle',
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
case 'explosion': {
|
|
876
|
+
// First 3 are expanding shockwave rings, rest are debris
|
|
877
|
+
if (i < 4) {
|
|
878
|
+
return {
|
|
879
|
+
x: originX, y: originY,
|
|
880
|
+
vx: 0, vy: 0, ax: 0, ay: 0,
|
|
881
|
+
size: 3 + i * 2,
|
|
882
|
+
color: pick(['#fbbf24','#fde68a','#fff']),
|
|
883
|
+
alpha: 0.85, life: 1, maxLife: 1,
|
|
884
|
+
wave: i * 0.12,
|
|
885
|
+
shape: 'arc',
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
const angle = Math.random() * Math.PI * 2;
|
|
889
|
+
const spd = rnd(speed.min, speed.max);
|
|
890
|
+
return {
|
|
891
|
+
x: originX + (Math.random()-0.5)*20,
|
|
892
|
+
y: originY + (Math.random()-0.5)*20,
|
|
893
|
+
vx: Math.cos(angle) * spd, vy: Math.sin(angle) * spd - 40,
|
|
894
|
+
ax: 0, ay: 200,
|
|
895
|
+
size: rnd(size.min, size.max),
|
|
896
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
897
|
+
shape: 'circle',
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
case 'fire': {
|
|
901
|
+
return {
|
|
902
|
+
x: originX + (Math.random() - 0.5) * size.max * 6,
|
|
903
|
+
y: originY,
|
|
904
|
+
vx: rnd(-20, 20),
|
|
905
|
+
vy: -(speed.min + Math.random() * (speed.max - speed.min)),
|
|
906
|
+
ax: rnd(-8, 8), ay: -10,
|
|
907
|
+
size: rnd(size.min, size.max),
|
|
908
|
+
color: colors[Math.floor(Math.random() * colors.length)],
|
|
909
|
+
alpha: 0.9, life: 1, maxLife: 1,
|
|
910
|
+
shape: 'circle',
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
case 'ripple': {
|
|
914
|
+
return {
|
|
915
|
+
x: originX, y: originY,
|
|
916
|
+
vx: 0, vy: 0, ax: 0, ay: 0,
|
|
917
|
+
size: 0,
|
|
918
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
919
|
+
wave: t * 0.5, // stagger rings by index
|
|
920
|
+
shape: 'arc',
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
default: {
|
|
924
|
+
const angle = Math.random() * Math.PI * 2;
|
|
925
|
+
const spd = rnd(speed.min, speed.max);
|
|
926
|
+
return {
|
|
927
|
+
x: originX, y: originY,
|
|
928
|
+
vx: Math.cos(angle)*spd, vy: Math.sin(angle)*spd,
|
|
929
|
+
ax: 0, ay: 30,
|
|
930
|
+
size: rnd(size.min, size.max),
|
|
931
|
+
color: pick(colors), alpha: 1, life: 1, maxLife: 1,
|
|
932
|
+
shape: 'circle',
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// Stagger spawning for shower/fire so screen doesn't flash all at once
|
|
939
|
+
const isStaggered = type === 'shower' || type === 'fire' || type === 'float' || type === 'ripple';
|
|
940
|
+
if (isStaggered) {
|
|
941
|
+
// Spawn first 20% immediately, rest over first 40% of duration
|
|
942
|
+
const duration = this._numAttr('duration', preset.duration);
|
|
943
|
+
for (let i = 0; i < count; i++) {
|
|
944
|
+
const delay = (i / count) * duration * 0.5;
|
|
945
|
+
setTimeout(() => {
|
|
946
|
+
if (!this._canvas) return;
|
|
947
|
+
particles.push(spawnParticle(i));
|
|
948
|
+
}, delay);
|
|
949
|
+
}
|
|
950
|
+
} else {
|
|
951
|
+
for (let i = 0; i < count; i++) particles.push(spawnParticle(i));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ── Per-particle draw ─────────────────────────────────────────────────
|
|
955
|
+
const drawParticle = (p: P) => {
|
|
956
|
+
ctx.globalAlpha = Math.max(0, p.life) * (p.alpha ?? 1);
|
|
957
|
+
ctx.fillStyle = p.color;
|
|
958
|
+
ctx.strokeStyle = p.color;
|
|
959
|
+
|
|
960
|
+
if (p.shape === 'rect' && p.rot !== undefined) {
|
|
961
|
+
ctx.save();
|
|
962
|
+
ctx.translate(p.x, p.y);
|
|
963
|
+
ctx.rotate(p.rot);
|
|
964
|
+
ctx.fillRect(-p.size / 2, -p.size * 1.5, p.size, p.size * 3);
|
|
965
|
+
ctx.restore();
|
|
966
|
+
|
|
967
|
+
} else if (p.shape === 'line' && p.trail && p.trail.length > 1) {
|
|
968
|
+
// Electricity bolt — draw jagged line segments from trail
|
|
969
|
+
ctx.lineWidth = p.size;
|
|
970
|
+
ctx.shadowColor = p.color;
|
|
971
|
+
ctx.shadowBlur = 12;
|
|
972
|
+
ctx.beginPath();
|
|
973
|
+
ctx.moveTo(p.trail[0][0], p.trail[0][1]);
|
|
974
|
+
for (let k = 1; k < p.trail.length; k++) {
|
|
975
|
+
ctx.lineTo(p.trail[k][0], p.trail[k][1]);
|
|
976
|
+
}
|
|
977
|
+
ctx.stroke();
|
|
978
|
+
ctx.shadowBlur = 0;
|
|
979
|
+
|
|
980
|
+
} else if (p.shape === 'arc') {
|
|
981
|
+
// Ripple ring OR explosion shockwave
|
|
982
|
+
const maxR = type === 'explosion'
|
|
983
|
+
? Math.max(W, H) * 0.35
|
|
984
|
+
: Math.max(W, H) * 0.45;
|
|
985
|
+
const radius = (1 - p.life) * maxR + (p.size ?? 0);
|
|
986
|
+
if (radius > 0) {
|
|
987
|
+
ctx.lineWidth = type === 'explosion' ? Math.max(1, p.size * p.life * 2) : (p.size > 0 ? p.size : 2);
|
|
988
|
+
ctx.beginPath();
|
|
989
|
+
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
|
|
990
|
+
ctx.stroke();
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
} else {
|
|
994
|
+
// Circle — fire, firework, explosion debris each get glow
|
|
995
|
+
if (type === 'fire') {
|
|
996
|
+
const [r,g,b] = hex2rgb(p.color);
|
|
997
|
+
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
|
998
|
+
grad.addColorStop(0, `rgba(${r},${g},${b},${p.life})`);
|
|
999
|
+
grad.addColorStop(0.6, `rgba(${r},${g},${b},${p.life*0.5})`);
|
|
1000
|
+
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
|
1001
|
+
ctx.fillStyle = grad;
|
|
1002
|
+
} else if (type === 'firework') {
|
|
1003
|
+
// Glowing spark — shrinks as it fades
|
|
1004
|
+
ctx.shadowColor = p.color;
|
|
1005
|
+
ctx.shadowBlur = p.size * 4;
|
|
1006
|
+
const drawR = Math.max(0.5, p.size * p.life);
|
|
1007
|
+
ctx.beginPath();
|
|
1008
|
+
ctx.arc(p.x, p.y, drawR, 0, Math.PI * 2);
|
|
1009
|
+
ctx.fill();
|
|
1010
|
+
ctx.shadowBlur = 0;
|
|
1011
|
+
ctx.globalAlpha = 1;
|
|
1012
|
+
return; // skip the generic arc below
|
|
1013
|
+
} else if (type === 'explosion') {
|
|
1014
|
+
const [r,g,b] = hex2rgb(p.color);
|
|
1015
|
+
const grad = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size);
|
|
1016
|
+
grad.addColorStop(0, `rgba(255,255,255,${p.life})`);
|
|
1017
|
+
grad.addColorStop(0.35, `rgba(${r},${g},${b},${p.life})`);
|
|
1018
|
+
grad.addColorStop(1, `rgba(${r},${g},${b},0)`);
|
|
1019
|
+
ctx.fillStyle = grad;
|
|
1020
|
+
ctx.shadowColor = p.color;
|
|
1021
|
+
ctx.shadowBlur = p.size * 2;
|
|
1022
|
+
}
|
|
1023
|
+
ctx.beginPath();
|
|
1024
|
+
ctx.arc(p.x, p.y, Math.max(0, p.size), 0, Math.PI * 2);
|
|
1025
|
+
ctx.fill();
|
|
1026
|
+
}
|
|
1027
|
+
ctx.globalAlpha = 1;
|
|
1028
|
+
ctx.shadowBlur = 0;
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
const maxElecBolts = 5;
|
|
1032
|
+
let elecTimer = 0;
|
|
1033
|
+
const elecInterval = 0.06; // seconds between new bolt sets
|
|
1034
|
+
|
|
1035
|
+
const loop = createAnimationLoop((dt) => {
|
|
1036
|
+
ctx.clearRect(0, 0, W, H);
|
|
1037
|
+
|
|
1038
|
+
// ── Electricity: regenerate jagged bolt paths each interval ────
|
|
1039
|
+
if (type === 'electricity') {
|
|
1040
|
+
elecTimer += dt;
|
|
1041
|
+
if (elecTimer >= elecInterval) {
|
|
1042
|
+
elecTimer = 0;
|
|
1043
|
+
// Re-generate all bolt trails from origin -> target
|
|
1044
|
+
const activeBolts = Math.min(maxElecBolts, particles.length);
|
|
1045
|
+
for (let b = 0; b < activeBolts; b++) {
|
|
1046
|
+
const p = particles[b];
|
|
1047
|
+
if (!p) continue;
|
|
1048
|
+
const steps = 12 + Math.floor(Math.random() * 8);
|
|
1049
|
+
const trail: Array<[number,number]> = [[originX, originY]];
|
|
1050
|
+
for (let s = 1; s < steps; s++) {
|
|
1051
|
+
const t2 = s / steps;
|
|
1052
|
+
const mx = originX + (targetX - originX) * t2;
|
|
1053
|
+
const my = originY + (targetY - originY) * t2;
|
|
1054
|
+
const jitter = (1 - Math.abs(t2 - 0.5) * 2) * 80;
|
|
1055
|
+
trail.push([
|
|
1056
|
+
mx + (Math.random()-0.5) * jitter,
|
|
1057
|
+
my + (Math.random()-0.5) * jitter,
|
|
1058
|
+
]);
|
|
1059
|
+
}
|
|
1060
|
+
trail.push([targetX, targetY]);
|
|
1061
|
+
p.trail = trail;
|
|
1062
|
+
p.life = 1;
|
|
1063
|
+
p.alpha = 0.6 + Math.random() * 0.4;
|
|
1064
|
+
}
|
|
1065
|
+
} else {
|
|
1066
|
+
// Fade between regenerations
|
|
1067
|
+
for (const p of particles) p.life = Math.max(0, p.life - dt * 6);
|
|
1068
|
+
}
|
|
1069
|
+
for (const p of particles) drawParticle(p);
|
|
1070
|
+
return true; // electricity loops until duration expires
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ── All other types ───────────────────────────────────────────
|
|
1074
|
+
let alive = 0;
|
|
1075
|
+
const fadeRate = type === 'ripple' ? 0.3 : type === 'fire' ? 0.7 :
|
|
1076
|
+
type === 'float' ? 0.15 :
|
|
1077
|
+
type === 'firework' ? 0.42 :
|
|
1078
|
+
type === 'explosion' ? 0.65 : 0.35;
|
|
1079
|
+
|
|
1080
|
+
for (const p of particles) {
|
|
1081
|
+
p.vx += p.ax * dt;
|
|
1082
|
+
p.vy += p.ay * dt;
|
|
1083
|
+
p.x += p.vx * dt;
|
|
1084
|
+
p.y += p.vy * dt;
|
|
1085
|
+
if (p.rot !== undefined && p.rotV !== undefined) p.rot += p.rotV * dt;
|
|
1086
|
+
// Ripple: shrink size for growing ring effect is handled in draw
|
|
1087
|
+
if (type !== 'ripple') {
|
|
1088
|
+
p.life = Math.max(0, p.life - dt * fadeRate);
|
|
1089
|
+
} else {
|
|
1090
|
+
// stagger rings with wave offset
|
|
1091
|
+
p.life = Math.max(0, p.life - dt * (0.35 + (p.wave ?? 0) * 0.1));
|
|
1092
|
+
}
|
|
1093
|
+
if (p.life <= 0.01) continue;
|
|
1094
|
+
alive++;
|
|
1095
|
+
drawParticle(p);
|
|
1096
|
+
}
|
|
1097
|
+
return alive > 0 || particles.length < count;
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
this._particleLoop = loop;
|
|
1101
|
+
loop.start();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
private _stopParticles() {
|
|
1105
|
+
this._particleWebGL?.stop();
|
|
1106
|
+
this._particleWebGL = null;
|
|
1107
|
+
this._particleLoop?.stop();
|
|
1108
|
+
this._particleLoop = null;
|
|
1109
|
+
if (this._canvas) {
|
|
1110
|
+
this._canvas.remove();
|
|
1111
|
+
this._canvas = null;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
1116
|
+
|
|
1117
|
+
/** First painted element assigned to the default slot. */
|
|
1118
|
+
private _target(): HTMLElement | null {
|
|
1119
|
+
const slot = this.shadowRoot?.querySelector<HTMLSlotElement>('slot');
|
|
1120
|
+
if (!slot) return null;
|
|
1121
|
+
const nodes = slot.assignedElements({ flatten: true });
|
|
1122
|
+
return (nodes[0] as HTMLElement) ?? null;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
private _attr(name: string, fallback: string): string {
|
|
1126
|
+
return this.getAttribute(name) ?? fallback;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
private _numAttr(name: string, fallback: number): number {
|
|
1130
|
+
const v = parseInt(this.getAttribute(name) ?? '', 10);
|
|
1131
|
+
return isNaN(v) ? fallback : v;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private _iterAttr(): number {
|
|
1135
|
+
const raw = this.getAttribute('iterations');
|
|
1136
|
+
if (!raw || raw === 'infinite') return 1;
|
|
1137
|
+
const n = parseInt(raw, 10);
|
|
1138
|
+
return isNaN(n) ? 1 : n;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
|
|
1142
|
+
if (oldValue === newValue || !this._mounted) return;
|
|
1143
|
+
// Re-setup on any attribute change (restarts trigger logic)
|
|
1144
|
+
this._teardown();
|
|
1145
|
+
this._visibleFired = false;
|
|
1146
|
+
this._setup();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
defineComponent('nc-animation', NcAnimation);
|