@tsparticles/interaction-external-attract 4.0.0-beta.9 → 4.0.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.
@@ -2,15 +2,19 @@ import { ExternalInteractorBase, mouseMoveEvent, } from "@tsparticles/plugin-int
2
2
  import { isInArray, millisecondsToSeconds } from "@tsparticles/engine";
3
3
  import { clickAttract, hoverAttract } from "./Utils.js";
4
4
  import { Attract } from "./Options/Classes/Attract.js";
5
- const attractMode = "attract";
5
+ const attractMode = "attract", minVelocityLengthSq = 0, minRestoreSpeed = 0.001, maxRestoreSpeed = 1, restoreEpsilon = 0.5;
6
6
  export class Attractor extends ExternalInteractorBase {
7
7
  handleClickMode;
8
+ _interactedThisFrame;
8
9
  _maxDistance;
9
10
  _pluginManager;
11
+ _restoreData;
10
12
  constructor(pluginManager, container) {
11
13
  super(container);
12
14
  this._pluginManager = pluginManager;
13
15
  this._maxDistance = 0;
16
+ this._interactedThisFrame = new Set();
17
+ this._restoreData = new Map();
14
18
  container.attract ??= { particles: [] };
15
19
  this.handleClickMode = (mode, interactivityData) => {
16
20
  const options = this.container.actualOptions, attract = options.interactivity?.modes.attract;
@@ -51,17 +55,23 @@ export class Attractor extends ExternalInteractorBase {
51
55
  container.retina.attractModeDistance = attract.distance * container.retina.pixelRatio;
52
56
  }
53
57
  interact(interactivityData) {
58
+ this._interactedThisFrame.clear();
54
59
  const container = this.container, options = container.actualOptions, mouseMoveStatus = interactivityData.status === mouseMoveEvent, events = options.interactivity?.events;
55
60
  if (!events) {
56
61
  return;
57
62
  }
58
63
  const { enable: hoverEnabled, mode: hoverMode } = events.onHover, { enable: clickEnabled, mode: clickMode } = events.onClick;
59
64
  if (mouseMoveStatus && hoverEnabled && isInArray(attractMode, hoverMode)) {
60
- hoverAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p));
65
+ hoverAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p), p => {
66
+ this._trackInteractedParticle(p);
67
+ });
61
68
  }
62
69
  else if (clickEnabled && isInArray(attractMode, clickMode)) {
63
- clickAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p));
70
+ clickAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p), p => {
71
+ this._trackInteractedParticle(p);
72
+ });
64
73
  }
74
+ this._restoreParticles();
65
75
  }
66
76
  isEnabled(interactivityData, particle) {
67
77
  const container = this.container, options = container.actualOptions, mouse = interactivityData.mouse, events = (particle?.interactivity ?? options.interactivity)?.events;
@@ -79,4 +89,61 @@ export class Attractor extends ExternalInteractorBase {
79
89
  }
80
90
  reset() {
81
91
  }
92
+ _restoreParticles() {
93
+ const restore = this.container.actualOptions.interactivity?.modes.attract?.restore;
94
+ if (!restore?.enable || !this._restoreData.size) {
95
+ return;
96
+ }
97
+ const now = Date.now(), restoreDelay = restore.delay * millisecondsToSeconds, restoreSpeed = Math.max(minRestoreSpeed, Math.min(maxRestoreSpeed, restore.speed));
98
+ for (const [particle, restoreData] of this._restoreData) {
99
+ if (this._interactedThisFrame.has(particle)) {
100
+ continue;
101
+ }
102
+ if (particle.destroyed) {
103
+ this._restoreData.delete(particle);
104
+ continue;
105
+ }
106
+ const target = restoreData.target;
107
+ if (now - restoreData.lastInteractionTime < restoreDelay) {
108
+ continue;
109
+ }
110
+ let dx = target.x - particle.position.x, dy = target.y - particle.position.y, dz = target.z - particle.position.z;
111
+ if (restore.follow && particle.options.move.enable) {
112
+ const { x: vx, y: vy, z: vz } = particle.velocity, velocityLengthSq = vx * vx + vy * vy + vz * vz;
113
+ if (velocityLengthSq > minVelocityLengthSq) {
114
+ const parallelScale = (dx * vx + dy * vy + dz * vz) / velocityLengthSq;
115
+ dx -= vx * parallelScale;
116
+ dy -= vy * parallelScale;
117
+ dz -= vz * parallelScale;
118
+ }
119
+ }
120
+ particle.position.x += dx * restoreSpeed;
121
+ particle.position.y += dy * restoreSpeed;
122
+ particle.position.z += dz * restoreSpeed;
123
+ if (Math.abs(dx) <= restoreEpsilon && Math.abs(dy) <= restoreEpsilon) {
124
+ particle.position.x = target.x;
125
+ particle.position.y = target.y;
126
+ particle.position.z = target.z;
127
+ this._restoreData.delete(particle);
128
+ continue;
129
+ }
130
+ }
131
+ }
132
+ _trackInteractedParticle(particle) {
133
+ this._interactedThisFrame.add(particle);
134
+ const restore = this.container.actualOptions.interactivity?.modes.attract?.restore;
135
+ if (!restore?.enable) {
136
+ return;
137
+ }
138
+ const now = Date.now();
139
+ let restoreData = this._restoreData.get(particle);
140
+ if (!restoreData) {
141
+ restoreData = {
142
+ target: particle.position.copy(),
143
+ lastInteractionTime: now,
144
+ };
145
+ this._restoreData.set(particle, restoreData);
146
+ }
147
+ restoreData.lastInteractionTime = now;
148
+ }
82
149
  }
@@ -5,6 +5,7 @@ export class Attract {
5
5
  easing;
6
6
  factor;
7
7
  maxSpeed;
8
+ restore;
8
9
  speed;
9
10
  constructor() {
10
11
  this.distance = 200;
@@ -13,6 +14,12 @@ export class Attract {
13
14
  this.factor = 1;
14
15
  this.maxSpeed = 50;
15
16
  this.speed = 1;
17
+ this.restore = {
18
+ enable: false,
19
+ delay: 0,
20
+ speed: 0.08,
21
+ follow: true,
22
+ };
16
23
  }
17
24
  load(data) {
18
25
  if (isNull(data)) {
@@ -36,5 +43,11 @@ export class Attract {
36
43
  if (data.speed !== undefined) {
37
44
  this.speed = data.speed;
38
45
  }
46
+ if (data.restore !== undefined) {
47
+ this.restore.enable = data.restore.enable ?? this.restore.enable;
48
+ this.restore.delay = data.restore.delay ?? this.restore.delay;
49
+ this.restore.speed = data.restore.speed ?? this.restore.speed;
50
+ this.restore.follow = data.restore.follow ?? this.restore.follow;
51
+ }
39
52
  }
40
53
  }
package/browser/Utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Circle, Vector, clamp, getDistances, identity, } from "@tsparticles/engine";
2
2
  const minFactor = 1, minRadius = 0, updateVector = Vector.origin;
3
- function processAttract(pluginManager, container, position, attractRadius, area, queryCb) {
3
+ function processAttract(pluginManager, container, position, attractRadius, area, queryCb, onAttractParticle) {
4
4
  const attractOptions = container.actualOptions.interactivity?.modes.attract;
5
5
  if (!attractOptions) {
6
6
  return;
@@ -10,10 +10,11 @@ function processAttract(pluginManager, container, position, attractRadius, area,
10
10
  const { dx, dy, distance } = getDistances(particle.position, position), velocity = attractOptions.speed * attractOptions.factor, attractFactor = clamp(pluginManager.getEasing(attractOptions.easing)(identity - distance / attractRadius) * velocity, minFactor, attractOptions.maxSpeed);
11
11
  updateVector.x = !distance ? velocity : (dx / distance) * attractFactor;
12
12
  updateVector.y = !distance ? velocity : (dy / distance) * attractFactor;
13
+ onAttractParticle?.(particle);
13
14
  particle.position.subFrom(updateVector);
14
15
  }
15
16
  }
16
- export function clickAttract(pluginManager, container, interactivityData, enabledCb) {
17
+ export function clickAttract(pluginManager, container, interactivityData, enabledCb, onAttractParticle) {
17
18
  container.attract ??= { particles: [] };
18
19
  const { attract } = container;
19
20
  if (!attract.finish) {
@@ -28,16 +29,16 @@ export function clickAttract(pluginManager, container, interactivityData, enable
28
29
  if (!attractRadius || attractRadius < minRadius || !mousePos) {
29
30
  return;
30
31
  }
31
- processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p));
32
+ processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p), onAttractParticle);
32
33
  }
33
34
  else if (attract.clicking === false) {
34
35
  attract.particles = [];
35
36
  }
36
37
  }
37
- export function hoverAttract(pluginManager, container, interactivityData, enabledCb) {
38
+ export function hoverAttract(pluginManager, container, interactivityData, enabledCb, onAttractParticle) {
38
39
  const mousePos = interactivityData.mouse.position, attractRadius = container.retina.attractModeDistance;
39
40
  if (!attractRadius || attractRadius < minRadius || !mousePos) {
40
41
  return;
41
42
  }
42
- processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p));
43
+ processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p), onAttractParticle);
43
44
  }
@@ -0,0 +1,5 @@
1
+ import { loadExternalAttractInteraction } from "./index.js";
2
+ const globalObject = globalThis;
3
+ globalObject.__tsParticlesInternals = globalObject.__tsParticlesInternals ?? {};
4
+ globalObject.loadExternalAttractInteraction = loadExternalAttractInteraction;
5
+ export * from "./index.js";
package/browser/index.js CHANGED
@@ -1,11 +1,11 @@
1
+ import { ensureInteractivityPluginLoaded } from "@tsparticles/plugin-interactivity";
2
+ import { Attractor } from "./Attractor.js";
1
3
  export async function loadExternalAttractInteraction(engine) {
2
- engine.checkVersion("4.0.0-beta.9");
3
- await engine.pluginManager.register(async (e) => {
4
- const { ensureInteractivityPluginLoaded } = await import("@tsparticles/plugin-interactivity");
4
+ engine.checkVersion("4.0.0");
5
+ await engine.pluginManager.register((e) => {
5
6
  ensureInteractivityPluginLoaded(e);
6
- e.pluginManager.addInteractor?.("externalAttract", async (container) => {
7
- const { Attractor } = await import("./Attractor.js");
8
- return new Attractor(e.pluginManager, container);
7
+ e.pluginManager.addInteractor?.("externalAttract", container => {
8
+ return Promise.resolve(new Attractor(e.pluginManager, container));
9
9
  });
10
10
  });
11
11
  }
@@ -0,0 +1,12 @@
1
+ export async function loadExternalAttractInteraction(engine) {
2
+ engine.checkVersion("4.0.0");
3
+ await engine.pluginManager.register(async (e) => {
4
+ const { ensureInteractivityPluginLoaded } = await import("@tsparticles/plugin-interactivity/lazy");
5
+ ensureInteractivityPluginLoaded(e);
6
+ e.pluginManager.addInteractor?.("externalAttract", async (container) => {
7
+ const { Attractor } = await import("./Attractor.js");
8
+ return new Attractor(e.pluginManager, container);
9
+ });
10
+ });
11
+ }
12
+ export * from "./Options/Classes/Attract.js";
package/cjs/Attractor.js CHANGED
@@ -2,15 +2,19 @@ import { ExternalInteractorBase, mouseMoveEvent, } from "@tsparticles/plugin-int
2
2
  import { isInArray, millisecondsToSeconds } from "@tsparticles/engine";
3
3
  import { clickAttract, hoverAttract } from "./Utils.js";
4
4
  import { Attract } from "./Options/Classes/Attract.js";
5
- const attractMode = "attract";
5
+ const attractMode = "attract", minVelocityLengthSq = 0, minRestoreSpeed = 0.001, maxRestoreSpeed = 1, restoreEpsilon = 0.5;
6
6
  export class Attractor extends ExternalInteractorBase {
7
7
  handleClickMode;
8
+ _interactedThisFrame;
8
9
  _maxDistance;
9
10
  _pluginManager;
11
+ _restoreData;
10
12
  constructor(pluginManager, container) {
11
13
  super(container);
12
14
  this._pluginManager = pluginManager;
13
15
  this._maxDistance = 0;
16
+ this._interactedThisFrame = new Set();
17
+ this._restoreData = new Map();
14
18
  container.attract ??= { particles: [] };
15
19
  this.handleClickMode = (mode, interactivityData) => {
16
20
  const options = this.container.actualOptions, attract = options.interactivity?.modes.attract;
@@ -51,17 +55,23 @@ export class Attractor extends ExternalInteractorBase {
51
55
  container.retina.attractModeDistance = attract.distance * container.retina.pixelRatio;
52
56
  }
53
57
  interact(interactivityData) {
58
+ this._interactedThisFrame.clear();
54
59
  const container = this.container, options = container.actualOptions, mouseMoveStatus = interactivityData.status === mouseMoveEvent, events = options.interactivity?.events;
55
60
  if (!events) {
56
61
  return;
57
62
  }
58
63
  const { enable: hoverEnabled, mode: hoverMode } = events.onHover, { enable: clickEnabled, mode: clickMode } = events.onClick;
59
64
  if (mouseMoveStatus && hoverEnabled && isInArray(attractMode, hoverMode)) {
60
- hoverAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p));
65
+ hoverAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p), p => {
66
+ this._trackInteractedParticle(p);
67
+ });
61
68
  }
62
69
  else if (clickEnabled && isInArray(attractMode, clickMode)) {
63
- clickAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p));
70
+ clickAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p), p => {
71
+ this._trackInteractedParticle(p);
72
+ });
64
73
  }
74
+ this._restoreParticles();
65
75
  }
66
76
  isEnabled(interactivityData, particle) {
67
77
  const container = this.container, options = container.actualOptions, mouse = interactivityData.mouse, events = (particle?.interactivity ?? options.interactivity)?.events;
@@ -79,4 +89,61 @@ export class Attractor extends ExternalInteractorBase {
79
89
  }
80
90
  reset() {
81
91
  }
92
+ _restoreParticles() {
93
+ const restore = this.container.actualOptions.interactivity?.modes.attract?.restore;
94
+ if (!restore?.enable || !this._restoreData.size) {
95
+ return;
96
+ }
97
+ const now = Date.now(), restoreDelay = restore.delay * millisecondsToSeconds, restoreSpeed = Math.max(minRestoreSpeed, Math.min(maxRestoreSpeed, restore.speed));
98
+ for (const [particle, restoreData] of this._restoreData) {
99
+ if (this._interactedThisFrame.has(particle)) {
100
+ continue;
101
+ }
102
+ if (particle.destroyed) {
103
+ this._restoreData.delete(particle);
104
+ continue;
105
+ }
106
+ const target = restoreData.target;
107
+ if (now - restoreData.lastInteractionTime < restoreDelay) {
108
+ continue;
109
+ }
110
+ let dx = target.x - particle.position.x, dy = target.y - particle.position.y, dz = target.z - particle.position.z;
111
+ if (restore.follow && particle.options.move.enable) {
112
+ const { x: vx, y: vy, z: vz } = particle.velocity, velocityLengthSq = vx * vx + vy * vy + vz * vz;
113
+ if (velocityLengthSq > minVelocityLengthSq) {
114
+ const parallelScale = (dx * vx + dy * vy + dz * vz) / velocityLengthSq;
115
+ dx -= vx * parallelScale;
116
+ dy -= vy * parallelScale;
117
+ dz -= vz * parallelScale;
118
+ }
119
+ }
120
+ particle.position.x += dx * restoreSpeed;
121
+ particle.position.y += dy * restoreSpeed;
122
+ particle.position.z += dz * restoreSpeed;
123
+ if (Math.abs(dx) <= restoreEpsilon && Math.abs(dy) <= restoreEpsilon) {
124
+ particle.position.x = target.x;
125
+ particle.position.y = target.y;
126
+ particle.position.z = target.z;
127
+ this._restoreData.delete(particle);
128
+ continue;
129
+ }
130
+ }
131
+ }
132
+ _trackInteractedParticle(particle) {
133
+ this._interactedThisFrame.add(particle);
134
+ const restore = this.container.actualOptions.interactivity?.modes.attract?.restore;
135
+ if (!restore?.enable) {
136
+ return;
137
+ }
138
+ const now = Date.now();
139
+ let restoreData = this._restoreData.get(particle);
140
+ if (!restoreData) {
141
+ restoreData = {
142
+ target: particle.position.copy(),
143
+ lastInteractionTime: now,
144
+ };
145
+ this._restoreData.set(particle, restoreData);
146
+ }
147
+ restoreData.lastInteractionTime = now;
148
+ }
82
149
  }
@@ -5,6 +5,7 @@ export class Attract {
5
5
  easing;
6
6
  factor;
7
7
  maxSpeed;
8
+ restore;
8
9
  speed;
9
10
  constructor() {
10
11
  this.distance = 200;
@@ -13,6 +14,12 @@ export class Attract {
13
14
  this.factor = 1;
14
15
  this.maxSpeed = 50;
15
16
  this.speed = 1;
17
+ this.restore = {
18
+ enable: false,
19
+ delay: 0,
20
+ speed: 0.08,
21
+ follow: true,
22
+ };
16
23
  }
17
24
  load(data) {
18
25
  if (isNull(data)) {
@@ -36,5 +43,11 @@ export class Attract {
36
43
  if (data.speed !== undefined) {
37
44
  this.speed = data.speed;
38
45
  }
46
+ if (data.restore !== undefined) {
47
+ this.restore.enable = data.restore.enable ?? this.restore.enable;
48
+ this.restore.delay = data.restore.delay ?? this.restore.delay;
49
+ this.restore.speed = data.restore.speed ?? this.restore.speed;
50
+ this.restore.follow = data.restore.follow ?? this.restore.follow;
51
+ }
39
52
  }
40
53
  }
package/cjs/Utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Circle, Vector, clamp, getDistances, identity, } from "@tsparticles/engine";
2
2
  const minFactor = 1, minRadius = 0, updateVector = Vector.origin;
3
- function processAttract(pluginManager, container, position, attractRadius, area, queryCb) {
3
+ function processAttract(pluginManager, container, position, attractRadius, area, queryCb, onAttractParticle) {
4
4
  const attractOptions = container.actualOptions.interactivity?.modes.attract;
5
5
  if (!attractOptions) {
6
6
  return;
@@ -10,10 +10,11 @@ function processAttract(pluginManager, container, position, attractRadius, area,
10
10
  const { dx, dy, distance } = getDistances(particle.position, position), velocity = attractOptions.speed * attractOptions.factor, attractFactor = clamp(pluginManager.getEasing(attractOptions.easing)(identity - distance / attractRadius) * velocity, minFactor, attractOptions.maxSpeed);
11
11
  updateVector.x = !distance ? velocity : (dx / distance) * attractFactor;
12
12
  updateVector.y = !distance ? velocity : (dy / distance) * attractFactor;
13
+ onAttractParticle?.(particle);
13
14
  particle.position.subFrom(updateVector);
14
15
  }
15
16
  }
16
- export function clickAttract(pluginManager, container, interactivityData, enabledCb) {
17
+ export function clickAttract(pluginManager, container, interactivityData, enabledCb, onAttractParticle) {
17
18
  container.attract ??= { particles: [] };
18
19
  const { attract } = container;
19
20
  if (!attract.finish) {
@@ -28,16 +29,16 @@ export function clickAttract(pluginManager, container, interactivityData, enable
28
29
  if (!attractRadius || attractRadius < minRadius || !mousePos) {
29
30
  return;
30
31
  }
31
- processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p));
32
+ processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p), onAttractParticle);
32
33
  }
33
34
  else if (attract.clicking === false) {
34
35
  attract.particles = [];
35
36
  }
36
37
  }
37
- export function hoverAttract(pluginManager, container, interactivityData, enabledCb) {
38
+ export function hoverAttract(pluginManager, container, interactivityData, enabledCb, onAttractParticle) {
38
39
  const mousePos = interactivityData.mouse.position, attractRadius = container.retina.attractModeDistance;
39
40
  if (!attractRadius || attractRadius < minRadius || !mousePos) {
40
41
  return;
41
42
  }
42
- processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p));
43
+ processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p), onAttractParticle);
43
44
  }
package/cjs/browser.js ADDED
@@ -0,0 +1,5 @@
1
+ import { loadExternalAttractInteraction } from "./index.js";
2
+ const globalObject = globalThis;
3
+ globalObject.__tsParticlesInternals = globalObject.__tsParticlesInternals ?? {};
4
+ globalObject.loadExternalAttractInteraction = loadExternalAttractInteraction;
5
+ export * from "./index.js";
package/cjs/index.js CHANGED
@@ -1,11 +1,11 @@
1
+ import { ensureInteractivityPluginLoaded } from "@tsparticles/plugin-interactivity";
2
+ import { Attractor } from "./Attractor.js";
1
3
  export async function loadExternalAttractInteraction(engine) {
2
- engine.checkVersion("4.0.0-beta.9");
3
- await engine.pluginManager.register(async (e) => {
4
- const { ensureInteractivityPluginLoaded } = await import("@tsparticles/plugin-interactivity");
4
+ engine.checkVersion("4.0.0");
5
+ await engine.pluginManager.register((e) => {
5
6
  ensureInteractivityPluginLoaded(e);
6
- e.pluginManager.addInteractor?.("externalAttract", async (container) => {
7
- const { Attractor } = await import("./Attractor.js");
8
- return new Attractor(e.pluginManager, container);
7
+ e.pluginManager.addInteractor?.("externalAttract", container => {
8
+ return Promise.resolve(new Attractor(e.pluginManager, container));
9
9
  });
10
10
  });
11
11
  }
@@ -0,0 +1,12 @@
1
+ export async function loadExternalAttractInteraction(engine) {
2
+ engine.checkVersion("4.0.0");
3
+ await engine.pluginManager.register(async (e) => {
4
+ const { ensureInteractivityPluginLoaded } = await import("@tsparticles/plugin-interactivity/lazy");
5
+ ensureInteractivityPluginLoaded(e);
6
+ e.pluginManager.addInteractor?.("externalAttract", async (container) => {
7
+ const { Attractor } = await import("./Attractor.js");
8
+ return new Attractor(e.pluginManager, container);
9
+ });
10
+ });
11
+ }
12
+ export * from "./Options/Classes/Attract.js";
package/esm/Attractor.js CHANGED
@@ -2,15 +2,19 @@ import { ExternalInteractorBase, mouseMoveEvent, } from "@tsparticles/plugin-int
2
2
  import { isInArray, millisecondsToSeconds } from "@tsparticles/engine";
3
3
  import { clickAttract, hoverAttract } from "./Utils.js";
4
4
  import { Attract } from "./Options/Classes/Attract.js";
5
- const attractMode = "attract";
5
+ const attractMode = "attract", minVelocityLengthSq = 0, minRestoreSpeed = 0.001, maxRestoreSpeed = 1, restoreEpsilon = 0.5;
6
6
  export class Attractor extends ExternalInteractorBase {
7
7
  handleClickMode;
8
+ _interactedThisFrame;
8
9
  _maxDistance;
9
10
  _pluginManager;
11
+ _restoreData;
10
12
  constructor(pluginManager, container) {
11
13
  super(container);
12
14
  this._pluginManager = pluginManager;
13
15
  this._maxDistance = 0;
16
+ this._interactedThisFrame = new Set();
17
+ this._restoreData = new Map();
14
18
  container.attract ??= { particles: [] };
15
19
  this.handleClickMode = (mode, interactivityData) => {
16
20
  const options = this.container.actualOptions, attract = options.interactivity?.modes.attract;
@@ -51,17 +55,23 @@ export class Attractor extends ExternalInteractorBase {
51
55
  container.retina.attractModeDistance = attract.distance * container.retina.pixelRatio;
52
56
  }
53
57
  interact(interactivityData) {
58
+ this._interactedThisFrame.clear();
54
59
  const container = this.container, options = container.actualOptions, mouseMoveStatus = interactivityData.status === mouseMoveEvent, events = options.interactivity?.events;
55
60
  if (!events) {
56
61
  return;
57
62
  }
58
63
  const { enable: hoverEnabled, mode: hoverMode } = events.onHover, { enable: clickEnabled, mode: clickMode } = events.onClick;
59
64
  if (mouseMoveStatus && hoverEnabled && isInArray(attractMode, hoverMode)) {
60
- hoverAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p));
65
+ hoverAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p), p => {
66
+ this._trackInteractedParticle(p);
67
+ });
61
68
  }
62
69
  else if (clickEnabled && isInArray(attractMode, clickMode)) {
63
- clickAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p));
70
+ clickAttract(this._pluginManager, this.container, interactivityData, p => this.isEnabled(interactivityData, p), p => {
71
+ this._trackInteractedParticle(p);
72
+ });
64
73
  }
74
+ this._restoreParticles();
65
75
  }
66
76
  isEnabled(interactivityData, particle) {
67
77
  const container = this.container, options = container.actualOptions, mouse = interactivityData.mouse, events = (particle?.interactivity ?? options.interactivity)?.events;
@@ -79,4 +89,61 @@ export class Attractor extends ExternalInteractorBase {
79
89
  }
80
90
  reset() {
81
91
  }
92
+ _restoreParticles() {
93
+ const restore = this.container.actualOptions.interactivity?.modes.attract?.restore;
94
+ if (!restore?.enable || !this._restoreData.size) {
95
+ return;
96
+ }
97
+ const now = Date.now(), restoreDelay = restore.delay * millisecondsToSeconds, restoreSpeed = Math.max(minRestoreSpeed, Math.min(maxRestoreSpeed, restore.speed));
98
+ for (const [particle, restoreData] of this._restoreData) {
99
+ if (this._interactedThisFrame.has(particle)) {
100
+ continue;
101
+ }
102
+ if (particle.destroyed) {
103
+ this._restoreData.delete(particle);
104
+ continue;
105
+ }
106
+ const target = restoreData.target;
107
+ if (now - restoreData.lastInteractionTime < restoreDelay) {
108
+ continue;
109
+ }
110
+ let dx = target.x - particle.position.x, dy = target.y - particle.position.y, dz = target.z - particle.position.z;
111
+ if (restore.follow && particle.options.move.enable) {
112
+ const { x: vx, y: vy, z: vz } = particle.velocity, velocityLengthSq = vx * vx + vy * vy + vz * vz;
113
+ if (velocityLengthSq > minVelocityLengthSq) {
114
+ const parallelScale = (dx * vx + dy * vy + dz * vz) / velocityLengthSq;
115
+ dx -= vx * parallelScale;
116
+ dy -= vy * parallelScale;
117
+ dz -= vz * parallelScale;
118
+ }
119
+ }
120
+ particle.position.x += dx * restoreSpeed;
121
+ particle.position.y += dy * restoreSpeed;
122
+ particle.position.z += dz * restoreSpeed;
123
+ if (Math.abs(dx) <= restoreEpsilon && Math.abs(dy) <= restoreEpsilon) {
124
+ particle.position.x = target.x;
125
+ particle.position.y = target.y;
126
+ particle.position.z = target.z;
127
+ this._restoreData.delete(particle);
128
+ continue;
129
+ }
130
+ }
131
+ }
132
+ _trackInteractedParticle(particle) {
133
+ this._interactedThisFrame.add(particle);
134
+ const restore = this.container.actualOptions.interactivity?.modes.attract?.restore;
135
+ if (!restore?.enable) {
136
+ return;
137
+ }
138
+ const now = Date.now();
139
+ let restoreData = this._restoreData.get(particle);
140
+ if (!restoreData) {
141
+ restoreData = {
142
+ target: particle.position.copy(),
143
+ lastInteractionTime: now,
144
+ };
145
+ this._restoreData.set(particle, restoreData);
146
+ }
147
+ restoreData.lastInteractionTime = now;
148
+ }
82
149
  }
@@ -5,6 +5,7 @@ export class Attract {
5
5
  easing;
6
6
  factor;
7
7
  maxSpeed;
8
+ restore;
8
9
  speed;
9
10
  constructor() {
10
11
  this.distance = 200;
@@ -13,6 +14,12 @@ export class Attract {
13
14
  this.factor = 1;
14
15
  this.maxSpeed = 50;
15
16
  this.speed = 1;
17
+ this.restore = {
18
+ enable: false,
19
+ delay: 0,
20
+ speed: 0.08,
21
+ follow: true,
22
+ };
16
23
  }
17
24
  load(data) {
18
25
  if (isNull(data)) {
@@ -36,5 +43,11 @@ export class Attract {
36
43
  if (data.speed !== undefined) {
37
44
  this.speed = data.speed;
38
45
  }
46
+ if (data.restore !== undefined) {
47
+ this.restore.enable = data.restore.enable ?? this.restore.enable;
48
+ this.restore.delay = data.restore.delay ?? this.restore.delay;
49
+ this.restore.speed = data.restore.speed ?? this.restore.speed;
50
+ this.restore.follow = data.restore.follow ?? this.restore.follow;
51
+ }
39
52
  }
40
53
  }
package/esm/Utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Circle, Vector, clamp, getDistances, identity, } from "@tsparticles/engine";
2
2
  const minFactor = 1, minRadius = 0, updateVector = Vector.origin;
3
- function processAttract(pluginManager, container, position, attractRadius, area, queryCb) {
3
+ function processAttract(pluginManager, container, position, attractRadius, area, queryCb, onAttractParticle) {
4
4
  const attractOptions = container.actualOptions.interactivity?.modes.attract;
5
5
  if (!attractOptions) {
6
6
  return;
@@ -10,10 +10,11 @@ function processAttract(pluginManager, container, position, attractRadius, area,
10
10
  const { dx, dy, distance } = getDistances(particle.position, position), velocity = attractOptions.speed * attractOptions.factor, attractFactor = clamp(pluginManager.getEasing(attractOptions.easing)(identity - distance / attractRadius) * velocity, minFactor, attractOptions.maxSpeed);
11
11
  updateVector.x = !distance ? velocity : (dx / distance) * attractFactor;
12
12
  updateVector.y = !distance ? velocity : (dy / distance) * attractFactor;
13
+ onAttractParticle?.(particle);
13
14
  particle.position.subFrom(updateVector);
14
15
  }
15
16
  }
16
- export function clickAttract(pluginManager, container, interactivityData, enabledCb) {
17
+ export function clickAttract(pluginManager, container, interactivityData, enabledCb, onAttractParticle) {
17
18
  container.attract ??= { particles: [] };
18
19
  const { attract } = container;
19
20
  if (!attract.finish) {
@@ -28,16 +29,16 @@ export function clickAttract(pluginManager, container, interactivityData, enable
28
29
  if (!attractRadius || attractRadius < minRadius || !mousePos) {
29
30
  return;
30
31
  }
31
- processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p));
32
+ processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p), onAttractParticle);
32
33
  }
33
34
  else if (attract.clicking === false) {
34
35
  attract.particles = [];
35
36
  }
36
37
  }
37
- export function hoverAttract(pluginManager, container, interactivityData, enabledCb) {
38
+ export function hoverAttract(pluginManager, container, interactivityData, enabledCb, onAttractParticle) {
38
39
  const mousePos = interactivityData.mouse.position, attractRadius = container.retina.attractModeDistance;
39
40
  if (!attractRadius || attractRadius < minRadius || !mousePos) {
40
41
  return;
41
42
  }
42
- processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p));
43
+ processAttract(pluginManager, container, mousePos, attractRadius, new Circle(mousePos.x, mousePos.y, attractRadius), (p) => enabledCb(p), onAttractParticle);
43
44
  }
package/esm/browser.js ADDED
@@ -0,0 +1,5 @@
1
+ import { loadExternalAttractInteraction } from "./index.js";
2
+ const globalObject = globalThis;
3
+ globalObject.__tsParticlesInternals = globalObject.__tsParticlesInternals ?? {};
4
+ globalObject.loadExternalAttractInteraction = loadExternalAttractInteraction;
5
+ export * from "./index.js";
package/esm/index.js CHANGED
@@ -1,11 +1,11 @@
1
+ import { ensureInteractivityPluginLoaded } from "@tsparticles/plugin-interactivity";
2
+ import { Attractor } from "./Attractor.js";
1
3
  export async function loadExternalAttractInteraction(engine) {
2
- engine.checkVersion("4.0.0-beta.9");
3
- await engine.pluginManager.register(async (e) => {
4
- const { ensureInteractivityPluginLoaded } = await import("@tsparticles/plugin-interactivity");
4
+ engine.checkVersion("4.0.0");
5
+ await engine.pluginManager.register((e) => {
5
6
  ensureInteractivityPluginLoaded(e);
6
- e.pluginManager.addInteractor?.("externalAttract", async (container) => {
7
- const { Attractor } = await import("./Attractor.js");
8
- return new Attractor(e.pluginManager, container);
7
+ e.pluginManager.addInteractor?.("externalAttract", container => {
8
+ return Promise.resolve(new Attractor(e.pluginManager, container));
9
9
  });
10
10
  });
11
11
  }