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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jakub Antalik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,73 @@
1
- # Temporary Holding Version
1
+ # react-native-magnetkit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Magnetic UI for React Native: a real magnet beside the phone pulls your
4
+ interface. An Expo module around MagnetKit, the on-device magnet model for
5
+ iOS (Swift) and Android (Kotlin), with React hooks, a `MagneticView` that the
6
+ magnet pulls, and a ready-made calibration screen.
7
+
8
+ Docs and the model: https://libraries.dev/magnetic
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npx expo install react-native-magnetkit
14
+ ```
15
+
16
+ The model (`Magnet.mfxl`, 2 MB) is not in this package. Download it from
17
+ https://libraries.dev/magnetic (Model tab), put it in your app's `assets/`,
18
+ and point the config plugin at it:
19
+
20
+ ```json
21
+ {
22
+ "expo": {
23
+ "plugins": [["react-native-magnetkit", { "model": "./assets/Magnet.mfxl" }]]
24
+ }
25
+ }
26
+ ```
27
+
28
+ The plugin copies it into the iOS app target and Android's assets at
29
+ prebuild. It ships native code, so use a development build
30
+ (`npx expo run:ios`, `npx expo run:android` or EAS), not Expo Go.
31
+
32
+ Bare React Native: run `npx install-expo-modules`, then add `Magnet.mfxl` to
33
+ the iOS target's Copy Bundle Resources and to `android/app/src/main/assets`.
34
+
35
+ ## Use
36
+
37
+ ```tsx
38
+ import { Magnet, MagnetCalibration, MagneticView, useMagnet, useMagnetGestures } from "react-native-magnetkit";
39
+
40
+ // Views the magnet pulls. They collide, and spring back when it leaves.
41
+ <MagneticView style={styles.chip}><Text>Home</Text></MagneticView>
42
+
43
+ // Once per phone and magnet: five rings, about 10 s, kept across launches.
44
+ <MagnetCalibration visible={calibrating} onClose={(ok) => setCalibrating(false)} />
45
+
46
+ // Readings and gestures.
47
+ const f = useMagnet(); // f.pos (window points), f.presence, f.pull, f.strength…
48
+ useMagnetGestures((g) => { if (g.g === "tap") openMenu(g.at); });
49
+ ```
50
+
51
+ - `useMagnet()` re-renders once per frame; `useMagnetFrame(cb)` gets every
52
+ reading without rendering.
53
+ - `Magnet.hasModel()`, `Magnet.zero()`, `Magnet.getMagnet()`,
54
+ `Magnet.clearMagnet()`, `Magnet.addFrameListener()`.
55
+ - `getMagneticField()` tunes the physics (stiffness, damping, force, reach).
56
+ - `calibrate({ onUpdate })` runs a calibration headless, to draw your own rings.
57
+
58
+ The sensor runs only while something listens. Keep the magnet 2 cm or more
59
+ from the sensor (`f.tooClose`).
60
+
61
+ ## Repository layout
62
+
63
+ The Swift and Kotlin sources come from `../../ios/MagnetKit` and
64
+ `../../android/magnetkit`, and the shared TypeScript (readings, calibration
65
+ fit, Magnetic UI physics) from `packages/magnetkit-web`. `npm run sync`
66
+ copies them in (`prepare` and `build` run it); the copies are not committed.
67
+ `../magnet-example` is a working app.
68
+
69
+ ## License
70
+
71
+ The code is MIT. The model is under the Libraries.dev Model License: free for
72
+ development, prototypes and non-commercial apps; a commercial app needs
73
+ Libraries.dev Pro or Business.
@@ -0,0 +1,18 @@
1
+ plugins {
2
+ id 'com.android.library'
3
+ id 'expo-module-gradle-plugin'
4
+ }
5
+
6
+ group = 'dev.libraries.magnetkit.rn'
7
+ version = '0.1.0'
8
+
9
+ android {
10
+ namespace "dev.libraries.magnetkit.rn"
11
+ defaultConfig {
12
+ versionCode 1
13
+ versionName "0.1.0"
14
+ }
15
+ lintOptions {
16
+ abortOnError false
17
+ }
18
+ }
@@ -0,0 +1,76 @@
1
+ package dev.libraries.magnetkit
2
+
3
+ import kotlin.math.atan2
4
+ import kotlin.math.cos
5
+ import kotlin.math.max
6
+ import kotlin.math.min
7
+ import kotlin.math.pow
8
+ import kotlin.math.sin
9
+ import kotlin.math.sqrt
10
+
11
+ /**
12
+ * Where an upright magnet is, worked out directly from the physics (a dipole
13
+ * whose axis points roughly at the screen). Needs the magnet's face; the
14
+ * strength only sets the distance. The same maths as MagnetKit's MagnetDipole.
15
+ */
16
+ object MagnetDipole {
17
+ /** cos²α from the field's vertical share q = face·Bz/|B| (closed form). */
18
+ fun cos2(q0: Double): Double {
19
+ val q = min(1.0, max(-1.0, q0))
20
+ val u = (q + sqrt(q * q + 8)) / 2
21
+ return min(1.0, max(0.0, (u * u - 1) / 3))
22
+ }
23
+
24
+ /** The magnet relative to the sensor (cm, device axes) and how much of the field lies in the screen's plane. */
25
+ fun locate(b: Vec3, face: Int, strength: Double, maxHeight: Double? = null): Pair<Vec3, Double>? {
26
+ val nb = b.length
27
+ if (nb <= 1e-6 || strength <= 0) return null
28
+ val f = if (face < 0) -1.0 else 1.0
29
+ val c2 = cos2(f * b.z / nb)
30
+ val c = sqrt(c2); val s = sqrt(max(0.0, 1 - c2))
31
+ var phi = atan2(b.y, b.x)
32
+ if (f < 0) phi += Math.PI
33
+ val r = (strength * sqrt(1 + 3 * c2) / nb).pow(1.0 / 3.0)
34
+ val inPlane = sqrt(b.x * b.x + b.y * b.y) / nb
35
+ var h = r * c; var lateral = r * s
36
+ if (maxHeight != null && h > maxHeight) { h = maxHeight; lateral = sqrt(max(0.0, r * r - h * h)) }
37
+ return Pair(Vec3(lateral * cos(phi), lateral * sin(phi), h), inPlane)
38
+ }
39
+
40
+ /**
41
+ * The face and strength from one reading with the magnet held over a known
42
+ * spot (`offsetX`, `offsetY`: from the sensor, cm, device axes): the face
43
+ * and height (0.6–3.6 cm) whose predicted field direction matches best.
44
+ */
45
+ fun learn(b: Vec3, offsetX: Double, offsetY: Double): Pair<Int, Double>? {
46
+ val nb = b.length
47
+ if (nb <= 1e-6 || sqrt(offsetX * offsetX + offsetY * offsetY) <= 0.5) return null
48
+ val u = b / nb
49
+ var best: Triple<Double, Int, Double>? = null
50
+ for (face in intArrayOf(1, -1)) {
51
+ val m = Vec3(0.0, 0.0, face.toDouble())
52
+ var h = 0.6
53
+ while (h <= 3.6001) {
54
+ val p = Vec3(offsetX, offsetY, h)
55
+ val r = p.length
56
+ val rh = -p / r
57
+ val dir = rh * (3 * m.dot(rh)) - m
58
+ val score = u.dot(dir / dir.length)
59
+ if (best == null || score > best.first) best = Triple(score, face, nb * r * r * r / dir.length)
60
+ h += 0.2
61
+ }
62
+ }
63
+ val b2 = best ?: return null
64
+ return if (b2.first > 0.6) Pair(b2.second, b2.third) else null
65
+ }
66
+
67
+ /** The strength from a magnet held over a spot far from the sensor, at roughly `height` cm above it. */
68
+ fun strength(b: Vec3, offsetX: Double, offsetY: Double, height: Double = 2.4): Double? {
69
+ val nb = b.length
70
+ val lateral = sqrt(offsetX * offsetX + offsetY * offsetY)
71
+ if (nb <= 1e-6 || lateral <= 0.5) return null
72
+ val r = sqrt(lateral * lateral + height * height)
73
+ val c = height / r
74
+ return nb * r * r * r / sqrt(1 + 3 * c * c)
75
+ }
76
+ }
@@ -0,0 +1,189 @@
1
+ package dev.libraries.magnetkit
2
+
3
+ import android.content.Context
4
+ import kotlin.math.exp
5
+ import kotlin.math.hypot
6
+ import kotlin.math.ln
7
+ import kotlin.math.ln1p
8
+ import kotlin.math.max
9
+ import kotlin.math.min
10
+
11
+ /**
12
+ * The magnet model: one on-device model that reads the phone's magnetometer
13
+ * and answers three things — where the magnet is, whether it is really there
14
+ * (or the phone's own magnetised parts are lying), and air taps. The same
15
+ * file as MagnetKit on iOS (Magnet.mfxl, ~2 MB, half precision).
16
+ * `MagnetSource` runs it every frame.
17
+ *
18
+ * The model is not part of this MIT library: it is licensed separately under
19
+ * the Libraries.dev Model License (LICENSE-MODEL.md) and downloaded from
20
+ * libraries.dev. Put Magnet.mfxl in your app's src/main/assets and
21
+ * `bundled` finds it.
22
+ */
23
+ class MagnetModel private constructor(
24
+ val version: String,
25
+ val locator: MagnetLocator,
26
+ val presence: MagnetPresence,
27
+ val tap: MagnetTap,
28
+ ) {
29
+ companion object {
30
+ @Volatile private var cached: MagnetModel? = null
31
+
32
+ /** The model in the app's assets (assets/Magnet.mfxl), or null if missing. */
33
+ fun bundled(context: Context): MagnetModel? = cached ?: synchronized(this) {
34
+ cached ?: runCatching { context.assets.open("Magnet.mfxl").use { it.readBytes() } }
35
+ .getOrNull()?.let { parse(it) }?.also { cached = it }
36
+ }
37
+
38
+ /** "MFXL", header (kind "magnet", version, parts with offsets and lengths), then each part. */
39
+ fun parse(data: ByteArray): MagnetModel? {
40
+ val header = MfxlNet.header(data) ?: return null
41
+ if (header.optString("kind") != "magnet") return null
42
+ val parts = header.optJSONArray("parts") ?: return null
43
+ val start = 8 + MfxlNet.headerLength(data)
44
+ fun part(name: String): ByteArray? {
45
+ for (i in 0 until parts.length()) {
46
+ val p = parts.getJSONObject(i)
47
+ if (p.optString("name") != name) continue
48
+ val off = start + p.getInt("offset"); val len = p.getInt("length")
49
+ if (off + len > data.size) return null
50
+ return data.copyOfRange(off, off + len)
51
+ }
52
+ return null
53
+ }
54
+ val l = part("locator")?.let { MagnetLocator.parse(it) } ?: return null
55
+ val p = part("presence")?.let { MagnetPresence.parse(it) } ?: return null
56
+ val t = part("tap")?.let { MagnetTap.parse(it) } ?: return null
57
+ return MagnetModel(header.optString("version", "1.0"), l, p, t)
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Where the magnet is, from one reading: position relative to the sensor (cm, device axes) and its 1-σ spread, cm. */
63
+ class MagnetLocator private constructor(
64
+ private val net: MfxlNet,
65
+ private val logbMid: Float,
66
+ private val logkMid: Float,
67
+ private val logrMid: Float,
68
+ private val lvMin: Float,
69
+ private val lvMax: Float,
70
+ val takesFace: Boolean,
71
+ ) {
72
+ fun locate(field: Vec3, strength: Double?, face: Int?): Pair<Vec3, Double>? {
73
+ val nb = field.length
74
+ if (nb <= 1e-6) return null
75
+ val knownFace = if (takesFace) face?.let { if (it < 0) -1.0 else 1.0 } else null
76
+ // Known face: turn the field to face +1. Unknown: a flipped magnet
77
+ // gives −B at the same spot, so the network sees one face (Bz ≥ 0).
78
+ val f = knownFace?.let { field * it } ?: if (field.z < 0) -field else field
79
+ val input = ArrayList<Float>(7)
80
+ input += (f.x / nb).toFloat(); input += (f.y / nb).toFloat(); input += (f.z / nb).toFloat()
81
+ input += (ln(nb).toFloat() - logbMid) / 2f
82
+ input += strength?.let { (ln(max(1.0, it)).toFloat() - logkMid) / 1.5f } ?: 0f
83
+ input += if (strength == null) 0f else 1f
84
+ if (takesFace) input += if (knownFace == null) 0f else 1f
85
+ val o = net.forward(input.toFloatArray())
86
+ val d = Vec3(o[0].toDouble(), o[1].toDouble(), o[2].toDouble())
87
+ val dl = d.length
88
+ if (dl <= 1e-9) return null
89
+ val r = exp((o[3] + logrMid).toDouble())
90
+ fun sd(v: Float) = exp(0.5 * min(lvMax, max(lvMin, v)))
91
+ val spread = r * hypot(sd(o[4]), sd(o[5]))
92
+ return Pair(d / dl * r, spread)
93
+ }
94
+
95
+ companion object {
96
+ fun parse(data: ByteArray): MagnetLocator? {
97
+ val net = MfxlNet.parse(data, "locator") ?: return null
98
+ val h = net.header
99
+ val version = h.optInt("version", 0)
100
+ if (version != 2 && version != 3) return null
101
+ val clamp = h.optJSONArray("logvarClamp")
102
+ if (net.inputs != (if (version == 3) 7 else 6) || net.outputs != 6) return null
103
+ return MagnetLocator(
104
+ net,
105
+ h.optDouble("logbMid", ln(40.0)).toFloat(), h.optDouble("logkMid", ln(10000.0)).toFloat(),
106
+ h.optDouble("logrMid", ln(5.0)).toFloat(),
107
+ (clamp?.optDouble(0, -10.0) ?: -10.0).toFloat(), (clamp?.optDouble(1, 4.0) ?: 4.0).toFloat(),
108
+ version == 3,
109
+ )
110
+ }
111
+ }
112
+ }
113
+
114
+ /** Is a magnet really there, or is the phone's own offset lying — from the last 15 s. */
115
+ class MagnetPresence private constructor(
116
+ private val net: MfxlNet,
117
+ val hz: Double, val short: Int, val longN: Int, val longStep: Int, val window: Double, val contextInputs: Int,
118
+ ) {
119
+ /**
120
+ * @param history the field change (µT, against the current zero) at [hz], oldest first, ending now.
121
+ * @param overloadedFor seconds since an unzeroed overload, told only right after the field drops; null: no.
122
+ * @return presence (0–1) and the non-magnet part of the reading now, µT.
123
+ */
124
+ fun evaluate(history: List<Vec3>, overloadedFor: Double? = null): Pair<Double, Vec3> {
125
+ val n = history.size
126
+ if (n < max(short, longN * longStep)) return Pair(1.0, Vec3.ZERO)
127
+ val last = history[n - 1]
128
+ val s = max(10.0, last.length)
129
+ val x = FloatArray(short * 3 + longN + 4 + contextInputs)
130
+ var k = 0
131
+ for (i in n - short until n) {
132
+ val v = history[i] / s
133
+ x[k++] = v.x.coerceIn(-6.0, 6.0).toFloat(); x[k++] = v.y.coerceIn(-6.0, 6.0).toFloat(); x[k++] = v.z.coerceIn(-6.0, 6.0).toFloat()
134
+ }
135
+ var j = n - longN * longStep
136
+ repeat(longN) { x[k++] = (ln1p(history[j].length / 10) / 6).toFloat(); j += longStep }
137
+ var peak = 0.0; var peakAt = 0
138
+ for ((i, v) in history.withIndex()) { val m = v.length; if (m > peak) { peak = m; peakAt = i } }
139
+ x[k++] = (ln1p(peak / 100) / 4).toFloat()
140
+ x[k++] = ((n - 1 - peakAt) / hz / 15).toFloat()
141
+ x[k++] = (ln1p(s / 10) / 6).toFloat()
142
+ x[k++] = if (peak > 2000) 1f else 0f
143
+ if (contextInputs >= 2) {
144
+ x[k++] = if (overloadedFor == null) 0f else 1f
145
+ x[k++] = overloadedFor?.let { min(1.6, ln1p(it / 10) / ln1p(60.0)).toFloat() } ?: 0f
146
+ }
147
+ val o = net.forward(x)
148
+ val p = 1 / (1 + exp(-o[0].toDouble()))
149
+ return Pair(p, Vec3(o[1].toDouble(), o[2].toDouble(), o[3].toDouble()) * s)
150
+ }
151
+
152
+ companion object {
153
+ fun parse(data: ByteArray): MagnetPresence? {
154
+ val net = MfxlNet.parse(data, "presence") ?: return null
155
+ val h = net.header
156
+ val short = h.optInt("short", 64); val longN = h.optInt("longN", 40); val ctx = h.optInt("context", 0)
157
+ if (net.inputs != short * 3 + longN + 4 + ctx || net.outputs != 4) return null
158
+ return MagnetPresence(net, h.optDouble("hz", 50.0), short, longN, h.optInt("longStep", 5), h.optDouble("window", 15.0), ctx)
159
+ }
160
+ }
161
+ }
162
+
163
+ /** Did an air tap — the hovering magnet dipped toward the glass and back — just finish, from the last 1.28 s. */
164
+ class MagnetTap private constructor(private val net: MfxlNet, val hz: Double, val window: Int) {
165
+ fun evaluate(history: List<Vec3>): Double {
166
+ if (history.size < window) return 0.0
167
+ val w = history.subList(history.size - window, history.size)
168
+ val mags = w.map { it.length }
169
+ val s = max(10.0, mags.sorted()[(window - 1) / 2]) // the lower median, as torch's
170
+ val x = FloatArray(window * 4 + 1)
171
+ var k = 0
172
+ for (v in w) {
173
+ x[k++] = (v.x / s).coerceIn(-8.0, 8.0).toFloat(); x[k++] = (v.y / s).coerceIn(-8.0, 8.0).toFloat(); x[k++] = (v.z / s).coerceIn(-8.0, 8.0).toFloat()
174
+ }
175
+ for (m in mags) x[k++] = ln(max(m, 1.0) / s).coerceIn(-4.0, 4.0).toFloat()
176
+ x[k] = (ln1p(s / 10) / 6).toFloat()
177
+ val o = net.forward(x)
178
+ return 1 / (1 + exp(-o[0].toDouble()))
179
+ }
180
+
181
+ companion object {
182
+ fun parse(data: ByteArray): MagnetTap? {
183
+ val net = MfxlNet.parse(data, "tap") ?: return null
184
+ val window = net.header.optInt("window", 64)
185
+ if (net.inputs != window * 4 + 1 || net.outputs != 1) return null
186
+ return MagnetTap(net, net.header.optDouble("hz", 50.0), window)
187
+ }
188
+ }
189
+ }