react-native-magnetkit 0.0.0-stage → 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -2
  3. package/android/build.gradle +18 -0
  4. package/android/src/main/java/dev/libraries/magnetkit/MagnetDipole.kt +76 -0
  5. package/android/src/main/java/dev/libraries/magnetkit/MagnetModel.kt +189 -0
  6. package/android/src/main/java/dev/libraries/magnetkit/MagnetSource.kt +598 -0
  7. package/android/src/main/java/dev/libraries/magnetkit/MfxlNet.kt +92 -0
  8. package/android/src/main/java/dev/libraries/magnetkit/Vec3.kt +30 -0
  9. package/android/src/main/java/dev/libraries/magnetkit/rn/MagnetKitModule.kt +172 -0
  10. package/app.plugin.js +1 -0
  11. package/build/MagnetCalibration.d.ts +13 -0
  12. package/build/MagnetCalibration.js +58 -0
  13. package/build/MagnetKitModule.d.ts +52 -0
  14. package/build/MagnetKitModule.js +2 -0
  15. package/build/MagneticView.d.ts +15 -0
  16. package/build/MagneticView.js +129 -0
  17. package/build/core/calibration-core.d.ts +43 -0
  18. package/build/core/calibration-core.js +84 -0
  19. package/build/core/field.d.ts +64 -0
  20. package/build/core/field.js +341 -0
  21. package/build/core/fit.d.ts +13 -0
  22. package/build/core/fit.js +50 -0
  23. package/build/core/types.d.ts +37 -0
  24. package/build/core/types.js +2 -0
  25. package/build/hooks.d.ts +7 -0
  26. package/build/hooks.js +29 -0
  27. package/build/index.d.ts +9 -0
  28. package/build/index.js +7 -0
  29. package/build/magnet.d.ts +29 -0
  30. package/build/magnet.js +66 -0
  31. package/expo-module.config.json +9 -0
  32. package/ios/MagnetKit/MFXLNet.swift +89 -0
  33. package/ios/MagnetKit/MagnetLocator.swift +252 -0
  34. package/ios/MagnetKit/MagnetModel.swift +56 -0
  35. package/ios/MagnetKit/MagnetPresence.swift +81 -0
  36. package/ios/MagnetKit/MagnetSource.swift +967 -0
  37. package/ios/MagnetKit/MagnetTap.swift +53 -0
  38. package/ios/MagnetKitModule.swift +147 -0
  39. package/ios/MagnetKitRN.podspec +26 -0
  40. package/package.json +54 -4
  41. package/plugin/withMagnetKit.js +60 -0
  42. package/src/MagnetCalibration.tsx +87 -0
  43. package/src/MagnetKitModule.ts +42 -0
  44. package/src/MagneticView.tsx +153 -0
  45. package/src/core/calibration-core.ts +112 -0
  46. package/src/core/field.ts +295 -0
  47. package/src/core/fit.ts +55 -0
  48. package/src/core/types.ts +32 -0
  49. package/src/hooks.ts +32 -0
  50. package/src/index.ts +9 -0
  51. package/src/magnet.ts +77 -0
@@ -0,0 +1,112 @@
1
+ /* Calibration without UI or transport: the magnet held still over a few
2
+ spots on the screen, fitted to the magnet's face, strength and the
3
+ sensor's spot. Shared by the web bridge and React Native, which each
4
+ hand it a `CalibrationIO`. */
5
+
6
+ import { fitCalibration, type CalibrationFit, type CalibrationSpot } from "./fit";
7
+ import type { MagnetFrame, Pt } from "./types";
8
+
9
+ export type CalibrationState = {
10
+ phase: "zeroing" | "spot" | "done" | "failed" | "cancelled";
11
+ /** Which spot (0-based) and how many. */
12
+ index: number;
13
+ total: number;
14
+ /** Where to draw the ring, in the app's own coordinates (client px, or RN points). */
15
+ spot: Pt | null;
16
+ /** 0…1, how long the magnet has been held still over this spot. */
17
+ progress: number;
18
+ /** The field reported right now, µT; null while no readings arrive. */
19
+ field: number | null;
20
+ /** True while the phone is taking its zero. */
21
+ zeroing: boolean;
22
+ fit: CalibrationFit | null;
23
+ };
24
+
25
+ export type CalibrationOptions = {
26
+ /** Spots as fractions of the viewport. Five by default: middle, top, bottom, left, right. */
27
+ spots?: Array<[number, number]>;
28
+ onUpdate?: (s: CalibrationState) => void;
29
+ };
30
+
31
+ /** What a calibration needs from where the readings come from. */
32
+ export type CalibrationIO = {
33
+ /** Take a new zero (keep magnets away). */
34
+ zero(): void;
35
+ addFrameListener(l: (f: MagnetFrame) => void): () => void;
36
+ /** Hand the fit to the phone. */
37
+ setMagnet(fit: CalibrationFit): void;
38
+ /** The area the rings are drawn in, in the app's own coordinates. */
39
+ viewport(): Pt;
40
+ /** The app's coordinates onto the screen's, as the readings use. */
41
+ toScreen(p: Pt): Pt;
42
+ /** The screen's size and density, as the readings use. */
43
+ screenSize(): Pt;
44
+ pointsPerCm: number;
45
+ };
46
+
47
+ export const DEFAULT_SPOTS: Array<[number, number]> = [[0.5, 0.45], [0.5, 0.12], [0.5, 0.88], [0.15, 0.5], [0.85, 0.5]];
48
+
49
+ /** Runs a calibration; resolves with the fit (already handed to the phone), or null. */
50
+ export function runCalibration(io: CalibrationIO, opts: CalibrationOptions = {}) {
51
+ const SPOTS = opts.spots ?? DEFAULT_SPOTS;
52
+ const spots: CalibrationSpot[] = [];
53
+ let i = 0;
54
+ let hold: MagnetFrame[] = [];
55
+ let lastFrame: MagnetFrame | null = null;
56
+ let lastFrameAt = 0;
57
+ const zeroUntil = performance.now() + 1500;
58
+ let done = false;
59
+ let resolve!: (f: CalibrationFit | null) => void;
60
+ const promise = new Promise<CalibrationFit | null>((r) => (resolve = r));
61
+
62
+ const spotAt = (k: number): Pt => {
63
+ const v = io.viewport();
64
+ return { x: SPOTS[k][0] * v.x, y: SPOTS[k][1] * v.y };
65
+ };
66
+ const state = (phase: CalibrationState["phase"], progress = 0, fit: CalibrationFit | null = null): CalibrationState => ({
67
+ phase, index: i, total: SPOTS.length, spot: phase === "spot" ? spotAt(i) : null, progress,
68
+ field: lastFrame && performance.now() - lastFrameAt < 2000 ? lastFrame.strength : null,
69
+ zeroing: !!lastFrame?.calibrating, fit,
70
+ });
71
+ const emit = (s: CalibrationState) => opts.onUpdate?.(s);
72
+
73
+ const finish = (phase: "done" | "failed" | "cancelled", fit: CalibrationFit | null) => {
74
+ if (done) return;
75
+ done = true;
76
+ off();
77
+ clearInterval(tick);
78
+ emit(state(phase, 1, fit));
79
+ resolve(fit);
80
+ };
81
+
82
+ io.zero();
83
+ emit(state("zeroing"));
84
+ const tick = setInterval(() => {
85
+ if (!done && performance.now() < zeroUntil) emit(state("zeroing"));
86
+ }, 300);
87
+
88
+ const off = io.addFrameListener((f) => {
89
+ lastFrame = f; lastFrameAt = performance.now();
90
+ if (performance.now() < zeroUntil || f.calibrating) { emit(state("zeroing")); return; }
91
+ if (f.strength < 15) { hold = []; emit(state("spot", 0)); return; }
92
+ hold.push(f);
93
+ hold = hold.filter((h) => h.t > f.t - 1200);
94
+ const med = (j: number) => { const v = hold.map((h) => h.delta[j]).sort((a, b) => a - b); return v[v.length >> 1]; };
95
+ const m: [number, number, number] = [med(0), med(1), med(2)];
96
+ const spread = Math.max(...hold.map((h) => Math.hypot(h.delta[0] - m[0], h.delta[1] - m[1], h.delta[2] - m[2])));
97
+ if (spread > Math.max(4, 0.12 * f.strength)) { hold = [f]; emit(state("spot", 0)); return; }
98
+ const progress = Math.min(1, (f.t - hold[0].t) / 1000);
99
+ emit(state("spot", progress));
100
+ if (progress < 1) return;
101
+ spots.push({ screen: io.toScreen(spotAt(i)), delta: m });
102
+ i++;
103
+ hold = [];
104
+ if (i < SPOTS.length) { emit(state("spot", 0)); return; }
105
+ const fit = fitCalibration(spots, io.screenSize(), io.pointsPerCm);
106
+ if (!fit) return finish("failed", null);
107
+ io.setMagnet(fit);
108
+ finish("done", fit);
109
+ });
110
+
111
+ return { promise, cancel: () => finish("cancelled", null) };
112
+ }
@@ -0,0 +1,295 @@
1
+ /* The Magnetic UI physics, ported from Magnetic Home (Android) / the iOS
2
+ wallet demo: every element is a small rigid body on a stiff damped string
3
+ to its place. A magnet beside the phone (not over the glass) pulls each by
4
+ its nearest point (1/d³, capped), so it also tilts; bodies collide and
5
+ rest against the screen's rounded edge; the strings bring everything back
6
+ when it leaves. All lengths in screen dp (= CSS px on Android Chrome). */
7
+
8
+ import type { MagnetFrame, Pt } from "./types";
9
+
10
+ export type Box = { left: number; top: number; right: number; bottom: number };
11
+
12
+ const w = (b: Box) => b.right - b.left;
13
+ const h = (b: Box) => b.bottom - b.top;
14
+ const offsetBox = (b: Box, dx: number, dy: number): Box => ({ left: b.left + dx, top: b.top + dy, right: b.right + dx, bottom: b.bottom + dy });
15
+ const inset = (b: Box, d: number): Box => ({ left: b.left + d, top: b.top + d, right: b.right - d, bottom: b.bottom - d });
16
+ const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
17
+
18
+ export class Particle {
19
+ home: Box = { left: 0, top: 0, right: 0, bottom: 0 };
20
+ corner = 8;
21
+ ox = 0;
22
+ oy = 0;
23
+ vx = 0;
24
+ vy = 0;
25
+ angle = 0;
26
+ spin = 0;
27
+ atEdge = false;
28
+ get mass() { return Math.max(1, (w(this.home) * h(this.home)) / 1000); }
29
+ get inertia() { return (this.mass * (w(this.home) ** 2 + h(this.home) ** 2)) / 12; }
30
+ get center(): Pt { return { x: (this.home.left + this.home.right) / 2 + this.ox, y: (this.home.top + this.home.bottom) / 2 + this.oy }; }
31
+ get resting() { return Math.abs(this.ox) < 0.2 && Math.abs(this.oy) < 0.2 && Math.abs(this.vx) < 2 && Math.abs(this.vy) < 2 && Math.abs(this.angle) < 0.001; }
32
+ }
33
+
34
+ export class MagneticField {
35
+ parts = new Map<string, Particle>();
36
+ magnet: Pt | null = null;
37
+ overGlass = false;
38
+ private lastT = 0;
39
+ private gate = 0;
40
+ private lostAt = 0;
41
+ private strengths: Array<[number, number]> = [];
42
+
43
+ screenCorner = 40;
44
+ stiffness = 150;
45
+ damping = 21;
46
+ force = 30000;
47
+ reach = 150;
48
+ maxForce = 26000;
49
+ standoff = 40;
50
+ softening = 40;
51
+ nearBand = 90;
52
+ farBand = 320;
53
+ tiltGain = 0.5;
54
+ maxTilt = 0.1;
55
+ spinStiffness = 80;
56
+ spinDamping = 12;
57
+ restitution = 0.3;
58
+ wallRestitution = 0.25;
59
+ restingSpeed = 250;
60
+ wallFriction = 0.35;
61
+
62
+ setHome(id: string, b: Box, corner: number) {
63
+ let p = this.parts.get(id);
64
+ if (!p) this.parts.set(id, (p = new Particle()));
65
+ p.home = b;
66
+ p.corner = corner;
67
+ }
68
+
69
+ get active() { return this.gate > 0.01 || [...this.parts.values()].some((p) => !p.resting); }
70
+
71
+ relax(now: number) { this.simulate(now, null); }
72
+
73
+ step(r: MagnetFrame, now: number, screenBox: Box, face: number) {
74
+ const dt = this.lastT > 0 ? Math.min(1 / 30, Math.max(1 / 240, now - this.lastT)) : 1 / 60;
75
+ const p = r.pos;
76
+ if (r.strength > 4 && p) {
77
+ const m = this.magnet;
78
+ if (m) {
79
+ const jump = Math.hypot(p.x - m.x, p.y - m.y);
80
+ const k = 1 - Math.exp(-dt / (jump > 200 ? 0.4 : 0.08));
81
+ this.magnet = { x: m.x + (p.x - m.x) * k, y: m.y + (p.y - m.y) * k };
82
+ } else this.magnet = p;
83
+ this.lostAt = now;
84
+ } else if (now - this.lostAt > 0.15) this.magnet = null;
85
+ // Leaving: the field falling well below its peak of the last half second.
86
+ this.strengths.push([now, r.strength]);
87
+ while (this.strengths.length && now - this.strengths[0][0] > 0.5) this.strengths.shift();
88
+ const recentMax = Math.max(...this.strengths.map((s) => s[1]));
89
+ const leaving = recentMax > 25 ? clamp((r.strength / recentMax - 0.15) / 0.3, 0, 1) : 1;
90
+ // Over the glass: no pull at all (the field along the face, or well inside and above).
91
+ const along = ((face < 0 ? -1 : 1) * r.delta[2]) / Math.max(1e-6, r.strength);
92
+ const m = this.magnet;
93
+ const inside = !!m && this.outside(m, screenBox) < -25 && (r.height ?? 0) > 0.8;
94
+ if (r.strength > 4) {
95
+ if (along > 0.2 || inside) this.overGlass = true;
96
+ else if (along < 0) this.overGlass = false;
97
+ }
98
+ let target = 0;
99
+ if (m && !this.overGlass) {
100
+ const field = clamp((r.strength - 10) / 25, 0, 1);
101
+ const real = clamp((r.presence - 0.2) / 0.4, 0, 1);
102
+ target = field * real * leaving;
103
+ }
104
+ this.gate += (target - this.gate) * (1 - Math.exp(-dt / (target > this.gate ? 0.08 : 0.04)));
105
+ this.simulate(now, screenBox);
106
+ }
107
+
108
+ private outside(p: Pt, s: Box) {
109
+ const dx = Math.max(s.left - p.x, p.x - s.right), dy = Math.max(s.top - p.y, p.y - s.bottom);
110
+ if (dx > 0 || dy > 0) return Math.hypot(Math.max(0, dx), Math.max(0, dy));
111
+ return Math.max(dx, dy);
112
+ }
113
+
114
+ private nearestEdge(m: Pt, s: Box): Pt {
115
+ const dl = m.x - s.left, dr = s.right - m.x, dt = m.y - s.top, db = s.bottom - m.y;
116
+ const mn = Math.min(dl, dr, dt, db);
117
+ return mn === dl ? { x: -1, y: 0 } : mn === dr ? { x: 1, y: 0 } : mn === dt ? { x: 0, y: -1 } : { x: 0, y: 1 };
118
+ }
119
+
120
+ private pullPoint(m: Pt, s: Box): Pt {
121
+ let qx = clamp(m.x, s.left, s.right), qy = clamp(m.y, s.top, s.bottom);
122
+ let nx = m.x - qx, ny = m.y - qy;
123
+ if (Math.hypot(nx, ny) < 1) {
124
+ const e = this.nearestEdge(m, s);
125
+ if (e.x) qx = e.x < 0 ? s.left : s.right;
126
+ else qy = e.y < 0 ? s.top : s.bottom;
127
+ nx = e.x; ny = e.y;
128
+ }
129
+ const l = Math.max(1e-6, Math.hypot(nx, ny));
130
+ return { x: qx + (nx / l) * this.standoff, y: qy + (ny / l) * this.standoff };
131
+ }
132
+
133
+ private proximity(m: Pt, s: Box) {
134
+ return clamp(1 - (this.outside(m, s) - this.nearBand) / (this.farBand - this.nearBand), 0, 1);
135
+ }
136
+
137
+ private direction(c: Pt, m: Pt, s: Box): Pt {
138
+ let rx = m.x - c.x, ry = m.y - c.y;
139
+ const rl = Math.max(1e-6, Math.hypot(rx, ry));
140
+ rx /= rl; ry /= rl;
141
+ let ex = m.x - clamp(m.x, s.left, s.right), ey = m.y - clamp(m.y, s.top, s.bottom);
142
+ if (Math.hypot(ex, ey) < 1) {
143
+ const e = this.nearestEdge(m, s);
144
+ ex = e.x; ey = e.y;
145
+ }
146
+ const el = Math.max(1e-6, Math.hypot(ex, ey));
147
+ ex /= el; ey /= el;
148
+ const k = 0.65;
149
+ let dx = ex * k + rx * (1 - k), dy = ey * k + ry * (1 - k);
150
+ const dl = Math.max(1e-6, Math.hypot(dx, dy));
151
+ return { x: dx / dl, y: dy / dl };
152
+ }
153
+
154
+ private simulate(now: number, screenBox: Box | null) {
155
+ const dt = this.lastT > 0 ? Math.min(1 / 30, Math.max(1 / 240, now - this.lastT)) : 1 / 60;
156
+ this.lastT = now;
157
+ for (const p of this.parts.values()) {
158
+ if (w(p.home) <= 0) continue;
159
+ let ax = -this.stiffness * p.ox - this.damping * p.vx;
160
+ let ay = -this.stiffness * p.oy - this.damping * p.vy;
161
+ let torque = 0;
162
+ const raw = this.magnet;
163
+ if (raw && screenBox && this.gate > 0.01) {
164
+ const m = this.pullPoint(raw, screenBox);
165
+ const c = p.center;
166
+ const pw = w(p.home), ph = h(p.home);
167
+ const box = offsetBox(p.home, p.ox, p.oy);
168
+ const qx = clamp(m.x, box.left, box.right), qy = clamp(m.y, box.top, box.bottom);
169
+ const dist = Math.hypot(m.x - qx, m.y - qy) + this.softening;
170
+ const f = Math.min(this.maxForce, this.force * (this.reach / dist) ** 3) * this.gate * this.proximity(raw, screenBox);
171
+ const u = this.direction(c, m, screenBox);
172
+ ax += f * u.x;
173
+ ay += f * u.y;
174
+ // Gripped by its nearest point: an off-centre pull turns it.
175
+ const ca = Math.cos(p.angle), sa = Math.sin(p.angle);
176
+ const lx = (m.x - c.x) * ca + (m.y - c.y) * sa, ly = -(m.x - c.x) * sa + (m.y - c.y) * ca;
177
+ const nlx = clamp(lx, -pw / 2, pw / 2), nly = clamp(ly, -ph / 2, ph / 2);
178
+ const nx = nlx * ca - nly * sa, ny = nlx * sa + nly * ca;
179
+ torque = (this.tiltGain * (nx * f * u.y - ny * f * u.x)) / ((pw * pw + ph * ph) / 12);
180
+ if (p.atEdge) torque *= 0.4;
181
+ }
182
+ p.vx += ax * dt; p.vy += ay * dt;
183
+ const v = Math.hypot(p.vx, p.vy);
184
+ if (v > 2600) { p.vx = (p.vx / v) * 2600; p.vy = (p.vy / v) * 2600; }
185
+ p.ox += p.vx * dt; p.oy += p.vy * dt;
186
+ p.spin += (torque - this.spinStiffness * p.angle - this.spinDamping * p.spin) * dt;
187
+ p.angle += p.spin * dt;
188
+ if (Math.abs(p.angle) > this.maxTilt) { p.angle = clamp(p.angle, -this.maxTilt, this.maxTilt); p.spin *= 0.4; }
189
+ }
190
+ if (!screenBox) return;
191
+ const list = [...this.parts.values()].filter((p) => w(p.home) > 0);
192
+ for (let it = 0; it < 4; it++) {
193
+ for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) this.collide(list[i], list[j]);
194
+ for (const p of list) this.contain(p, screenBox);
195
+ }
196
+ for (const p of list) p.angle = clamp(p.angle, -this.maxTilt, this.maxTilt);
197
+ }
198
+
199
+ /** Two boxes (separating axes): push apart by mass; a real knock bounces and spins them. */
200
+ private collide(a: Particle, b: Particle) {
201
+ const ca = a.center, cb = b.center;
202
+ const haw = w(a.home) / 2, hah = h(a.home) / 2, hbw = w(b.home) / 2, hbh = h(b.home) / 2;
203
+ if (Math.abs(cb.x - ca.x) > haw + hbw + 20 || Math.abs(cb.y - ca.y) > hah + hbh + 20) return;
204
+ const axA = [{ x: Math.cos(a.angle), y: Math.sin(a.angle) }, { x: -Math.sin(a.angle), y: Math.cos(a.angle) }];
205
+ const axB = [{ x: Math.cos(b.angle), y: Math.sin(b.angle) }, { x: -Math.sin(b.angle), y: Math.cos(b.angle) }];
206
+ const dx = cb.x - ca.x, dy = cb.y - ca.y;
207
+ let best = Infinity, nX = 0, nY = 0;
208
+ for (const n of [...axA, ...axB]) {
209
+ const ra = haw * Math.abs(axA[0].x * n.x + axA[0].y * n.y) + hah * Math.abs(axA[1].x * n.x + axA[1].y * n.y);
210
+ const rb = hbw * Math.abs(axB[0].x * n.x + axB[0].y * n.y) + hbh * Math.abs(axB[1].x * n.x + axB[1].y * n.y);
211
+ const dist = dx * n.x + dy * n.y;
212
+ const overlap = ra + rb - Math.abs(dist);
213
+ if (overlap <= 0) return;
214
+ if (overlap < best) { best = overlap; nX = dist < 0 ? -n.x : n.x; nY = dist < 0 ? -n.y : n.y; }
215
+ }
216
+ // Page elements can overlap where they live (a title's box over its icon):
217
+ // those pairs never collide, or they would shove each other forever.
218
+ const ha = a.home, hb = b.home;
219
+ if (ha.left < hb.right - 1 && hb.left < ha.right - 1 && ha.top < hb.bottom - 1 && hb.top < ha.bottom - 1) return;
220
+ const wa = 1 / a.mass, wb = 1 / b.mass;
221
+ const k = best / (wa + wb);
222
+ a.ox -= nX * k * wa; a.oy -= nY * k * wa;
223
+ b.ox += nX * k * wb; b.oy += nY * k * wb;
224
+ const vn = (b.vx - a.vx) * nX + (b.vy - a.vy) * nY;
225
+ if (vn < 0) {
226
+ const knock = -vn > this.restingSpeed;
227
+ const j = (-(1 + (knock ? this.restitution : 0)) * vn) / (wa + wb);
228
+ a.vx -= nX * j * wa; a.vy -= nY * j * wa;
229
+ b.vx += nX * j * wb; b.vy += nY * j * wb;
230
+ if (!knock) return;
231
+ const pcx = (ca.x + cb.x) / 2, pcy = (ca.y + cb.y) / 2;
232
+ a.spin -= (0.5 * ((pcx - ca.x) * nY - (pcy - ca.y) * nX) * j) / a.inertia;
233
+ b.spin += (0.5 * ((pcx - cb.x) * nY - (pcy - cb.y) * nX) * j) / b.inertia;
234
+ }
235
+ }
236
+
237
+ /** Keep a body inside the screen's rounded rectangle. A body whose home is
238
+ * already cut by the screen's edge may rest there, but go no further out. */
239
+ private contain(p: Particle, screenBox: Box) {
240
+ const s: Box = {
241
+ left: Math.min(screenBox.left, p.home.left), top: Math.min(screenBox.top, p.home.top),
242
+ right: Math.max(screenBox.right, p.home.right), bottom: Math.max(screenBox.bottom, p.home.bottom),
243
+ };
244
+ const c = p.center;
245
+ const hw = w(p.home) / 2, hh = h(p.home) / 2;
246
+ const r = Math.min(p.corner, Math.min(hw, hh));
247
+ const inner = inset(s, r);
248
+ const rr = Math.max(0, this.screenCorner - r);
249
+ const ca = Math.cos(p.angle), sa = Math.sin(p.angle);
250
+ let wx = 0, wy = 0, worst = 0, wpx = c.x, wpy = c.y;
251
+ for (const [sx, sy] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
252
+ const lx = sx * (hw - r), ly = sy * (hh - r);
253
+ const qx = c.x + lx * ca - ly * sa, qy = c.y + lx * sa + ly * ca;
254
+ const corr = pushInside(qx, qy, inner, rr);
255
+ const len = Math.hypot(corr.x, corr.y);
256
+ if (len > worst) { worst = len; wx = corr.x; wy = corr.y; wpx = qx; wpy = qy; }
257
+ }
258
+ if (worst < 0.5) { p.atEdge = false; return; }
259
+ const wasAtEdge = p.atEdge;
260
+ p.atEdge = true;
261
+ p.ox += wx; p.oy += wy;
262
+ const nx = wx / worst, ny = wy / worst;
263
+ const vn = p.vx * nx + p.vy * ny;
264
+ if (vn < 0) {
265
+ // Held against the edge: it just stops there, friction holds it. A real hit bounces a little.
266
+ const resting = wasAtEdge && -vn < 900;
267
+ const hit = resting ? 0 : -vn;
268
+ const e = resting ? 0 : this.wallRestitution;
269
+ p.vx -= (1 + e) * vn * nx; p.vy -= (1 + e) * vn * ny;
270
+ const vn2 = p.vx * nx + p.vy * ny;
271
+ const tx = p.vx - vn2 * nx, ty = p.vy - vn2 * ny;
272
+ const tl = Math.hypot(tx, ty);
273
+ if (tl > 1e-6) {
274
+ const drop = resting ? Math.min(tl, this.wallFriction * -vn) / tl : 0.15;
275
+ p.vx -= tx * drop; p.vy -= ty * drop;
276
+ }
277
+ if (resting) p.spin *= 0.7;
278
+ else p.spin += (0.5 * ((wpx - c.x) * ny - (wpy - c.y) * nx) * hit * p.mass) / p.inertia;
279
+ }
280
+ }
281
+ }
282
+
283
+ function pushInside(qx: number, qy: number, rect: Box, radius: number): Pt {
284
+ const cx = qx < rect.left + radius ? rect.left + radius : qx > rect.right - radius ? rect.right - radius : null;
285
+ const cy = qy < rect.top + radius ? rect.top + radius : qy > rect.bottom - radius ? rect.bottom - radius : null;
286
+ if (cx != null && cy != null && radius > 0) {
287
+ const d = Math.hypot(qx - cx, qy - cy);
288
+ if (d <= radius) return { x: 0, y: 0 };
289
+ return { x: ((cx - qx) * (d - radius)) / d, y: ((cy - qy) * (d - radius)) / d };
290
+ }
291
+ let vx = 0, vy = 0;
292
+ if (qx < rect.left) vx = rect.left - qx; else if (qx > rect.right) vx = rect.right - qx;
293
+ if (qy < rect.top) vy = rect.top - qy; else if (qy > rect.bottom) vy = rect.bottom - qy;
294
+ return { x: vx, y: vy };
295
+ }
@@ -0,0 +1,55 @@
1
+ /* ── Calibration ───────────────────────────────────────────────────────
2
+ The magnet held still over a few known spots. One magnetometer gives
3
+ three numbers per spot; a dipole whose axis points at the screen,
4
+ seen from the sensor, explains them with four unknowns: which face is
5
+ toward the phone, its strength, where the sensor sits, and how high
6
+ the magnet was held. A grid over those, the strength solved in closed
7
+ form, picks the best fit. */
8
+
9
+ import type { Pt } from "./types";
10
+
11
+ export type CalibrationSpot = { screen: Pt; delta: [number, number, number] };
12
+ export type CalibrationFit = { face: 1 | -1; strength: number; sensor: Pt; height: number; error: number };
13
+
14
+ /** Android dp per cm (160 dp per inch); an iPhone is ~60 pt per cm. */
15
+ const DP_PER_CM = 160 / 2.54;
16
+
17
+ function unitField(offX: number, offY: number, h: number, face: number): [number, number, number] {
18
+ // Field at the sensor from a unit dipole (moment along ±z) at (offX, offY, h) cm.
19
+ const r = Math.hypot(offX, offY, h);
20
+ const rx = -offX / r, ry = -offY / r, rz = -h / r;
21
+ const mDotR = face * rz;
22
+ const k = 1 / (r * r * r);
23
+ return [k * 3 * mDotR * rx, k * 3 * mDotR * ry, k * (3 * mDotR * rz - face)];
24
+ }
25
+
26
+ export function fitCalibration(spots: CalibrationSpot[], screenDp: Pt, PPCM = DP_PER_CM): CalibrationFit | null {
27
+ if (spots.length < 3) return null;
28
+ const norm = spots.reduce((a, s) => a + s.delta[0] ** 2 + s.delta[1] ** 2 + s.delta[2] ** 2, 0);
29
+ let best: CalibrationFit | null = null;
30
+ for (const face of [1, -1] as const) {
31
+ for (let sx = 0; sx <= screenDp.x; sx += 8) {
32
+ for (let sy = -30; sy <= screenDp.y * 0.5; sy += 8) {
33
+ for (let h = 0.6; h <= 5.01; h += 0.2) {
34
+ let bg = 0, gg = 0;
35
+ const gs = spots.map((s) => {
36
+ const g = unitField((s.screen.x - sx) / PPCM, -(s.screen.y - sy) / PPCM, h, face);
37
+ bg += g[0] * s.delta[0] + g[1] * s.delta[1] + g[2] * s.delta[2];
38
+ gg += g[0] * g[0] + g[1] * g[1] + g[2] * g[2];
39
+ return g;
40
+ });
41
+ const k = bg / gg;
42
+ if (!(k > 0)) continue;
43
+ let err = 0;
44
+ spots.forEach((s, i) => {
45
+ const g = gs[i];
46
+ err += (s.delta[0] - k * g[0]) ** 2 + (s.delta[1] - k * g[1]) ** 2 + (s.delta[2] - k * g[2]) ** 2;
47
+ });
48
+ err /= norm;
49
+ if (!best || err < best.error) best = { face, strength: k, sensor: { x: sx, y: sy }, height: h, error: err };
50
+ }
51
+ }
52
+ }
53
+ }
54
+ return best;
55
+ }
@@ -0,0 +1,32 @@
1
+ /* What the magnet model answers, shared by the web bridge and React Native. */
2
+
3
+ export type Pt = { x: number; y: number };
4
+
5
+ export type MagnetFrame = {
6
+ t: number;
7
+ /** Where the magnet is, screen dp (null while the locator is unsure). */
8
+ pos: Pt | null;
9
+ spread: number;
10
+ height: number | null;
11
+ presence: number;
12
+ pull: number;
13
+ strength: number;
14
+ /** Field change from the zero, µT, device axes (x right, y up, z out of the screen). */
15
+ delta: [number, number, number];
16
+ tooClose: boolean;
17
+ calibrating: boolean;
18
+ };
19
+
20
+ export type MagnetGesture =
21
+ | { g: "tap"; at: Pt | null }
22
+ | { g: "push" | "pullAway"; speed: number }
23
+ | { g: "swipe"; dir: Pt; speed: number };
24
+
25
+ export type ModelHello = {
26
+ screenDp: [number, number];
27
+ grabStage: number;
28
+ face: number | null;
29
+ strength: number | null;
30
+ sensorDp: [number, number];
31
+ model: boolean;
32
+ };
package/src/hooks.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Magnet } from "./magnet";
3
+ import type { NativeMagnetFrame, NativeMagnetGesture } from "./MagnetKitModule";
4
+
5
+ /** Every reading, without re-rendering (once per display frame). */
6
+ export function useMagnetFrame(cb: (f: NativeMagnetFrame) => void) {
7
+ const ref = useRef(cb);
8
+ ref.current = cb;
9
+ useEffect(() => Magnet.addFrameListener((f) => ref.current(f)), []);
10
+ }
11
+
12
+ /** The latest reading as state, at most one render per animation frame. */
13
+ export function useMagnet(): NativeMagnetFrame | null {
14
+ const [frame, setFrame] = useState<NativeMagnetFrame | null>(Magnet.frame);
15
+ useEffect(() => {
16
+ let raf = 0;
17
+ let pending: NativeMagnetFrame | null = null;
18
+ const off = Magnet.addFrameListener((f) => {
19
+ pending = f;
20
+ if (!raf) raf = requestAnimationFrame(() => { raf = 0; setFrame(pending); });
21
+ });
22
+ return () => { off(); cancelAnimationFrame(raf); };
23
+ }, []);
24
+ return frame;
25
+ }
26
+
27
+ /** Air taps, pushes, pull-aways and swipes. */
28
+ export function useMagnetGestures(cb: (g: NativeMagnetGesture) => void) {
29
+ const ref = useRef(cb);
30
+ ref.current = cb;
31
+ useEffect(() => Magnet.addGestureListener((g) => ref.current(g)), []);
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export { Magnet } from "./magnet";
2
+ export type { NativeMagnetFrame as MagnetFrame, NativeMagnetGesture as MagnetGesture, StoredMagnet } from "./MagnetKitModule";
3
+ export type { Pt } from "./core/types";
4
+ export { useMagnet, useMagnetFrame, useMagnetGestures } from "./hooks";
5
+ export { MagneticView, getMagneticField, type MagneticViewProps } from "./MagneticView";
6
+ export { MagnetCalibration, calibrate, type MagnetCalibrationProps } from "./MagnetCalibration";
7
+ export { DEFAULT_SPOTS, type CalibrationState, type CalibrationOptions } from "./core/calibration-core";
8
+ export { fitCalibration, type CalibrationFit, type CalibrationSpot } from "./core/fit";
9
+ export { MagneticField, Particle, type Box } from "./core/field";
package/src/magnet.ts ADDED
@@ -0,0 +1,77 @@
1
+ /* One magnet session for the app: the native side runs while anything
2
+ listens and stops when the last listener leaves. */
3
+
4
+ import Native, { type NativeMagnetFrame, type NativeMagnetGesture, type StoredMagnet } from "./MagnetKitModule";
5
+ import type { CalibrationFit } from "./core/fit";
6
+
7
+ type Subscription = { remove(): void };
8
+
9
+ let users = 0;
10
+ let frameSub: Subscription | null = null;
11
+ const frameListeners = new Set<(f: NativeMagnetFrame) => void>();
12
+ const gestureListeners = new Set<(g: NativeMagnetGesture) => void>();
13
+ let gestureSub: Subscription | null = null;
14
+
15
+ function retain() {
16
+ if (users++ > 0) return;
17
+ frameSub = Native.addListener("onFrame", (f) => {
18
+ Magnet.frame = f;
19
+ Magnet.frameAt = performance.now();
20
+ frameListeners.forEach((l) => l(f));
21
+ });
22
+ gestureSub = Native.addListener("onGesture", (g) => gestureListeners.forEach((l) => l(g)));
23
+ Native.start();
24
+ }
25
+
26
+ function release() {
27
+ if (--users > 0) return;
28
+ users = 0;
29
+ frameSub?.remove();
30
+ gestureSub?.remove();
31
+ frameSub = gestureSub = null;
32
+ Magnet.frame = null;
33
+ Native.stop();
34
+ }
35
+
36
+ export const Magnet = {
37
+ /** The latest reading while anything listens. */
38
+ frame: null as NativeMagnetFrame | null,
39
+ /** When it arrived, performance.now() ms (its own `t` is the native clock). */
40
+ frameAt: 0,
41
+
42
+ /** The phone has a magnetometer. */
43
+ isAvailable: (): boolean => Native.isAvailable(),
44
+ /** Magnet.mfxl is in the app (see the config plugin). Without it readings
45
+ * still come, but nothing is located and there are no air taps. */
46
+ hasModel: (): boolean => Native.hasModel(),
47
+
48
+ /** Every reading, once per display frame. Starts the sensor; returns an unsubscribe function. */
49
+ addFrameListener(l: (f: NativeMagnetFrame) => void) {
50
+ frameListeners.add(l);
51
+ retain();
52
+ return () => {
53
+ if (frameListeners.delete(l)) release();
54
+ };
55
+ },
56
+
57
+ /** Air taps, pushes, pull-aways and swipes. Starts the sensor; returns an unsubscribe function. */
58
+ addGestureListener(l: (g: NativeMagnetGesture) => void) {
59
+ gestureListeners.add(l);
60
+ retain();
61
+ return () => {
62
+ if (gestureListeners.delete(l)) release();
63
+ };
64
+ },
65
+
66
+ /** Take a new zero (keep magnets away for a second). */
67
+ zero: () => Native.zero(),
68
+
69
+ /** The calibrated magnet, kept across launches; null before a calibration. */
70
+ getMagnet: (): StoredMagnet | null => Native.getMagnet(),
71
+ setMagnet: (fit: Pick<CalibrationFit, "face" | "strength" | "sensor">) =>
72
+ Native.setMagnet(fit.face, fit.strength, fit.sensor.x, fit.sensor.y),
73
+ clearMagnet: () => Native.clearMagnet(),
74
+
75
+ /** The screen as the readings measure it: size (points / dp) and points per cm. */
76
+ screen: () => Native.screen(),
77
+ };