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.
- package/LICENSE +21 -0
- package/README.md +72 -2
- package/android/build.gradle +18 -0
- package/android/src/main/java/dev/libraries/magnetkit/MagnetDipole.kt +76 -0
- package/android/src/main/java/dev/libraries/magnetkit/MagnetModel.kt +189 -0
- package/android/src/main/java/dev/libraries/magnetkit/MagnetSource.kt +598 -0
- package/android/src/main/java/dev/libraries/magnetkit/MfxlNet.kt +92 -0
- package/android/src/main/java/dev/libraries/magnetkit/Vec3.kt +30 -0
- package/android/src/main/java/dev/libraries/magnetkit/rn/MagnetKitModule.kt +172 -0
- package/app.plugin.js +1 -0
- package/build/MagnetCalibration.d.ts +13 -0
- package/build/MagnetCalibration.js +58 -0
- package/build/MagnetKitModule.d.ts +52 -0
- package/build/MagnetKitModule.js +2 -0
- package/build/MagneticView.d.ts +15 -0
- package/build/MagneticView.js +129 -0
- package/build/core/calibration-core.d.ts +43 -0
- package/build/core/calibration-core.js +84 -0
- package/build/core/field.d.ts +64 -0
- package/build/core/field.js +341 -0
- package/build/core/fit.d.ts +13 -0
- package/build/core/fit.js +50 -0
- package/build/core/types.d.ts +37 -0
- package/build/core/types.js +2 -0
- package/build/hooks.d.ts +7 -0
- package/build/hooks.js +29 -0
- package/build/index.d.ts +9 -0
- package/build/index.js +7 -0
- package/build/magnet.d.ts +29 -0
- package/build/magnet.js +66 -0
- package/expo-module.config.json +9 -0
- package/ios/MagnetKit/MFXLNet.swift +89 -0
- package/ios/MagnetKit/MagnetLocator.swift +252 -0
- package/ios/MagnetKit/MagnetModel.swift +56 -0
- package/ios/MagnetKit/MagnetPresence.swift +81 -0
- package/ios/MagnetKit/MagnetSource.swift +967 -0
- package/ios/MagnetKit/MagnetTap.swift +53 -0
- package/ios/MagnetKitModule.swift +147 -0
- package/ios/MagnetKitRN.podspec +26 -0
- package/package.json +54 -4
- package/plugin/withMagnetKit.js +60 -0
- package/src/MagnetCalibration.tsx +87 -0
- package/src/MagnetKitModule.ts +42 -0
- package/src/MagneticView.tsx +153 -0
- package/src/core/calibration-core.ts +112 -0
- package/src/core/field.ts +295 -0
- package/src/core/fit.ts +55 -0
- package/src/core/types.ts +32 -0
- package/src/hooks.ts +32 -0
- package/src/index.ts +9 -0
- package/src/magnet.ts +77 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package dev.libraries.magnetkit
|
|
2
|
+
|
|
3
|
+
import kotlin.math.sqrt
|
|
4
|
+
|
|
5
|
+
/** A 3-D vector in device axes (x right, y toward the top edge, z out of the screen). */
|
|
6
|
+
data class Vec3(val x: Double, val y: Double, val z: Double) {
|
|
7
|
+
operator fun plus(o: Vec3) = Vec3(x + o.x, y + o.y, z + o.z)
|
|
8
|
+
operator fun minus(o: Vec3) = Vec3(x - o.x, y - o.y, z - o.z)
|
|
9
|
+
operator fun times(k: Double) = Vec3(x * k, y * k, z * k)
|
|
10
|
+
operator fun div(k: Double) = Vec3(x / k, y / k, z / k)
|
|
11
|
+
operator fun unaryMinus() = Vec3(-x, -y, -z)
|
|
12
|
+
fun dot(o: Vec3) = x * o.x + y * o.y + z * o.z
|
|
13
|
+
val length: Double get() = sqrt(x * x + y * y + z * z)
|
|
14
|
+
|
|
15
|
+
companion object {
|
|
16
|
+
val ZERO = Vec3(0.0, 0.0, 0.0)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A point or vector on the screen, dp (x right, y down). */
|
|
21
|
+
data class Pt(val x: Double, val y: Double) {
|
|
22
|
+
operator fun plus(o: Pt) = Pt(x + o.x, y + o.y)
|
|
23
|
+
operator fun minus(o: Pt) = Pt(x - o.x, y - o.y)
|
|
24
|
+
operator fun times(k: Double) = Pt(x * k, y * k)
|
|
25
|
+
val length: Double get() = sqrt(x * x + y * y)
|
|
26
|
+
|
|
27
|
+
companion object {
|
|
28
|
+
val ZERO = Pt(0.0, 0.0)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
package dev.libraries.magnetkit.rn
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.os.Build
|
|
5
|
+
import android.util.DisplayMetrics
|
|
6
|
+
import android.view.Choreographer
|
|
7
|
+
import android.view.WindowManager
|
|
8
|
+
import dev.libraries.magnetkit.MagnetGesture
|
|
9
|
+
import dev.libraries.magnetkit.MagnetModel
|
|
10
|
+
import dev.libraries.magnetkit.MagnetSource
|
|
11
|
+
import dev.libraries.magnetkit.Pt
|
|
12
|
+
import expo.modules.kotlin.functions.Queues
|
|
13
|
+
import expo.modules.kotlin.modules.Module
|
|
14
|
+
import expo.modules.kotlin.modules.ModuleDefinition
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* MagnetKit for React Native: runs `MagnetSource` (the magnet model) every
|
|
18
|
+
* display frame while JavaScript listens, and sends each reading and each
|
|
19
|
+
* gesture as an event. Positions are dp from the window's top left, as React
|
|
20
|
+
* Native lays out (edge to edge). The model (Magnet.mfxl) comes from the
|
|
21
|
+
* app's assets.
|
|
22
|
+
*/
|
|
23
|
+
class MagnetKitModule : Module() {
|
|
24
|
+
private var source: MagnetSource? = null
|
|
25
|
+
private var gestureId = 0
|
|
26
|
+
private var running = false
|
|
27
|
+
private var screen = Pt(0.0, 0.0)
|
|
28
|
+
|
|
29
|
+
private val context: Context get() = requireNotNull(appContext.reactContext) { "React context is gone" }
|
|
30
|
+
private val prefs get() = context.getSharedPreferences("magnetkit-rn", Context.MODE_PRIVATE)
|
|
31
|
+
|
|
32
|
+
private val frame = object : Choreographer.FrameCallback {
|
|
33
|
+
override fun doFrame(frameTimeNanos: Long) {
|
|
34
|
+
if (!running) return
|
|
35
|
+
tick(frameTimeNanos / 1e9)
|
|
36
|
+
Choreographer.getInstance().postFrameCallback(this)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override fun definition() = ModuleDefinition {
|
|
41
|
+
Name("MagnetKit")
|
|
42
|
+
|
|
43
|
+
Events("onFrame", "onGesture")
|
|
44
|
+
|
|
45
|
+
Function("isAvailable") { MagnetSource.get(context).isSensorAvailable }
|
|
46
|
+
|
|
47
|
+
Function("hasModel") { MagnetModel.bundled(context) != null }
|
|
48
|
+
|
|
49
|
+
AsyncFunction("start") { start() }.runOnQueue(Queues.MAIN)
|
|
50
|
+
|
|
51
|
+
AsyncFunction("stop") { stop() }.runOnQueue(Queues.MAIN)
|
|
52
|
+
|
|
53
|
+
AsyncFunction("zero") { source?.calibrate() }.runOnQueue(Queues.MAIN)
|
|
54
|
+
|
|
55
|
+
AsyncFunction("setMagnet") { face: Int, strength: Double, x: Double, y: Double ->
|
|
56
|
+
prefs.edit()
|
|
57
|
+
.putInt("face", if (face < 0) -1 else 1)
|
|
58
|
+
.putFloat("strength", strength.toFloat())
|
|
59
|
+
.putFloat("sensorX", x.toFloat())
|
|
60
|
+
.putFloat("sensorY", y.toFloat())
|
|
61
|
+
.apply()
|
|
62
|
+
applyMagnet()
|
|
63
|
+
}.runOnQueue(Queues.MAIN)
|
|
64
|
+
|
|
65
|
+
AsyncFunction("clearMagnet") {
|
|
66
|
+
prefs.edit().clear().apply()
|
|
67
|
+
applyMagnet()
|
|
68
|
+
}.runOnQueue(Queues.MAIN)
|
|
69
|
+
|
|
70
|
+
Function("getMagnet") {
|
|
71
|
+
val p = prefs
|
|
72
|
+
if (!p.contains("face")) return@Function null
|
|
73
|
+
mapOf(
|
|
74
|
+
"face" to p.getInt("face", 1),
|
|
75
|
+
"strength" to p.getFloat("strength", 0f).toDouble(),
|
|
76
|
+
"sensor" to mapOf("x" to p.getFloat("sensorX", 0f).toDouble(), "y" to p.getFloat("sensorY", 0f).toDouble()),
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
Function("screen") {
|
|
81
|
+
val s = screenDp()
|
|
82
|
+
mapOf("width" to s.x, "height" to s.y, "pointsPerCm" to MagnetSource.get(context).config.pointsPerCm)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
OnDestroy {
|
|
86
|
+
android.os.Handler(android.os.Looper.getMainLooper()).post { stop() }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The calibrated magnet, or a guess (sensor near the top centre, face and
|
|
91
|
+
* strength unknown) so the locator still answers before a calibration. */
|
|
92
|
+
private fun applyMagnet() {
|
|
93
|
+
val s = source ?: MagnetSource.get(context)
|
|
94
|
+
val cfg = s.config
|
|
95
|
+
screen = screenDp()
|
|
96
|
+
cfg.screenSize = screen
|
|
97
|
+
val p = prefs
|
|
98
|
+
if (p.contains("face")) {
|
|
99
|
+
cfg.magnetFace = p.getInt("face", 1)
|
|
100
|
+
cfg.magnetStrength = p.getFloat("strength", 5000f).toDouble()
|
|
101
|
+
cfg.sensorPosition = Pt(p.getFloat("sensorX", 0f).toDouble(), p.getFloat("sensorY", 0f).toDouble())
|
|
102
|
+
} else {
|
|
103
|
+
cfg.magnetFace = null
|
|
104
|
+
cfg.magnetStrength = null
|
|
105
|
+
cfg.sensorPosition = Pt(screen.x / 2, 1.1 * cfg.pointsPerCm)
|
|
106
|
+
}
|
|
107
|
+
cfg.locatorEnabled = true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private fun start() {
|
|
111
|
+
if (running) return
|
|
112
|
+
val s = MagnetSource.get(context)
|
|
113
|
+
source = s
|
|
114
|
+
applyMagnet()
|
|
115
|
+
s.attach()
|
|
116
|
+
gestureId = s.addGestureListener { g -> sendEvent("onGesture", gesture(g)) }
|
|
117
|
+
running = true
|
|
118
|
+
Choreographer.getInstance().postFrameCallback(frame)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private fun stop() {
|
|
122
|
+
if (!running) return
|
|
123
|
+
running = false
|
|
124
|
+
Choreographer.getInstance().removeFrameCallback(frame)
|
|
125
|
+
source?.let { it.removeGestureListener(gestureId); it.detach() }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private fun tick(now: Double) {
|
|
129
|
+
val r = (source ?: return).reading(now)
|
|
130
|
+
sendEvent(
|
|
131
|
+
"onFrame",
|
|
132
|
+
mapOf(
|
|
133
|
+
"t" to now * 1000,
|
|
134
|
+
"pos" to r.screenPosition?.let { mapOf("x" to it.x, "y" to it.y) },
|
|
135
|
+
"spread" to r.screenSpread,
|
|
136
|
+
"height" to r.height,
|
|
137
|
+
"presence" to r.presence,
|
|
138
|
+
"pull" to r.pull,
|
|
139
|
+
"strength" to r.strength,
|
|
140
|
+
"delta" to listOf(r.delta.x, r.delta.y, r.delta.z),
|
|
141
|
+
"toward" to mapOf("x" to r.toward.x, "y" to r.toward.y),
|
|
142
|
+
"polarity" to r.polarity,
|
|
143
|
+
"distance" to r.distance.takeIf { it.isFinite() },
|
|
144
|
+
"approachSpeed" to r.approachSpeed,
|
|
145
|
+
"tooClose" to r.tooClose,
|
|
146
|
+
"calibrating" to r.calibrating,
|
|
147
|
+
"zeroed" to r.calibrated,
|
|
148
|
+
),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun gesture(g: MagnetGesture): Map<String, Any?> = when (g) {
|
|
153
|
+
is MagnetGesture.Tap -> mapOf("g" to "tap", "at" to g.at?.let { mapOf("x" to it.x, "y" to it.y) })
|
|
154
|
+
is MagnetGesture.Push -> mapOf("g" to "push", "speed" to g.speed)
|
|
155
|
+
is MagnetGesture.PullAway -> mapOf("g" to "pullAway", "speed" to g.speed)
|
|
156
|
+
is MagnetGesture.Swipe -> mapOf("g" to "swipe", "dir" to mapOf("x" to g.direction.x, "y" to g.direction.y), "speed" to g.speed)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The window's size, dp. */
|
|
160
|
+
private fun screenDp(): Pt {
|
|
161
|
+
val ctx = context
|
|
162
|
+
val d = ctx.resources.displayMetrics.density.toDouble()
|
|
163
|
+
return if (Build.VERSION.SDK_INT >= 30) {
|
|
164
|
+
val b = ctx.getSystemService(WindowManager::class.java).maximumWindowMetrics.bounds
|
|
165
|
+
Pt(b.width() / d, b.height() / d)
|
|
166
|
+
} else {
|
|
167
|
+
@Suppress("DEPRECATION")
|
|
168
|
+
val m = DisplayMetrics().also { ctx.getSystemService(WindowManager::class.java).defaultDisplay.getRealMetrics(it) }
|
|
169
|
+
Pt(m.widthPixels / d, m.heightPixels / d)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
package/app.plugin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = require("./plugin/withMagnetKit");
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type CalibrationOptions } from "./core/calibration-core";
|
|
2
|
+
/** Runs a calibration; resolves with the fit (already kept by the phone), or null. */
|
|
3
|
+
export declare function calibrate(opts?: CalibrationOptions): {
|
|
4
|
+
promise: Promise<import(".").CalibrationFit | null>;
|
|
5
|
+
cancel: () => void;
|
|
6
|
+
};
|
|
7
|
+
export type MagnetCalibrationProps = {
|
|
8
|
+
visible: boolean;
|
|
9
|
+
/** true when a calibration was kept. */
|
|
10
|
+
onClose?: (ok: boolean) => void;
|
|
11
|
+
};
|
|
12
|
+
/** A ready-made calibration screen: five rings, full screen, cancel button. */
|
|
13
|
+
export declare function MagnetCalibration({ visible, onClose }: MagnetCalibrationProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/* Calibration: the magnet held still over five rings, fitted to its face,
|
|
3
|
+
strength and where the sensor sits, kept on the phone across launches.
|
|
4
|
+
`calibrate()` runs it headless; <MagnetCalibration> is a ready-made
|
|
5
|
+
full-screen version. */
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { Dimensions, Modal, Pressable, StyleSheet, Text, View } from "react-native";
|
|
8
|
+
import { runCalibration } from "./core/calibration-core";
|
|
9
|
+
import { Magnet } from "./magnet";
|
|
10
|
+
/** Runs a calibration; resolves with the fit (already kept by the phone), or null. */
|
|
11
|
+
export function calibrate(opts = {}) {
|
|
12
|
+
const screen = Magnet.screen();
|
|
13
|
+
return runCalibration({
|
|
14
|
+
zero: () => { Magnet.zero(); },
|
|
15
|
+
addFrameListener: (l) => Magnet.addFrameListener(l),
|
|
16
|
+
setMagnet: (fit) => { Magnet.setMagnet(fit); },
|
|
17
|
+
viewport: () => {
|
|
18
|
+
const w = Dimensions.get("window");
|
|
19
|
+
return { x: w.width, y: w.height };
|
|
20
|
+
},
|
|
21
|
+
// The window is the screen (edge to edge), as the readings measure it.
|
|
22
|
+
toScreen: (p) => p,
|
|
23
|
+
screenSize: () => ({ x: screen.width, y: screen.height }),
|
|
24
|
+
pointsPerCm: screen.pointsPerCm,
|
|
25
|
+
}, opts);
|
|
26
|
+
}
|
|
27
|
+
/** A ready-made calibration screen: five rings, full screen, cancel button. */
|
|
28
|
+
export function MagnetCalibration({ visible, onClose }) {
|
|
29
|
+
const [s, setS] = useState(null);
|
|
30
|
+
const closeRef = useRef(onClose);
|
|
31
|
+
closeRef.current = onClose;
|
|
32
|
+
const cancelRef = useRef(() => { });
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!visible)
|
|
35
|
+
return;
|
|
36
|
+
const run = calibrate({ onUpdate: setS });
|
|
37
|
+
cancelRef.current = run.cancel;
|
|
38
|
+
run.promise.then((fit) => closeRef.current?.(!!fit));
|
|
39
|
+
return () => run.cancel();
|
|
40
|
+
}, [visible]);
|
|
41
|
+
const open = visible && !!s && s.phase !== "done" && s.phase !== "cancelled";
|
|
42
|
+
const spot = s?.spot ?? null;
|
|
43
|
+
const win = Dimensions.get("window");
|
|
44
|
+
const msg = !s ? "" : s.phase === "zeroing" ? "Keep the magnet away for a moment…"
|
|
45
|
+
: s.phase === "failed" ? "That didn't fit. Try again, holding the magnet still over each ring."
|
|
46
|
+
: `Ring ${s.index + 1} of ${s.total}: hold the magnet 1–2 cm above it and keep it still.`;
|
|
47
|
+
const diag = !s ? "" : s.field == null ? "No readings yet." : `${s.field.toFixed(0)} µT${s.zeroing ? " · zeroing" : ""}`;
|
|
48
|
+
return (_jsx(Modal, { visible: open, transparent: true, animationType: "fade", statusBarTranslucent: true, navigationBarTranslucent: true, onRequestClose: () => cancelRef.current(), children: _jsxs(View, { style: styles.root, children: [spot && (_jsx(View, { style: [styles.ring, { left: spot.x - 36, top: spot.y - 36 }], children: _jsx(View, { style: [styles.fill, { transform: [{ scale: s?.progress ?? 0 }] }] }) })), _jsx(Text, { style: [styles.msg, { top: spot ? (spot.y < win.height / 2 ? spot.y + 64 : spot.y - 120) : win.height * 0.4 }], children: msg }), _jsx(Text, { style: styles.diag, children: diag }), _jsx(Pressable, { onPress: () => cancelRef.current(), style: styles.cancel, accessibilityRole: "button", children: _jsx(Text, { style: styles.cancelText, children: "Cancel" }) })] }) }));
|
|
49
|
+
}
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
root: { flex: 1, backgroundColor: "rgba(0,0,0,0.94)" },
|
|
52
|
+
ring: { position: "absolute", width: 72, height: 72, borderRadius: 36, borderWidth: 3, borderColor: "#fff" },
|
|
53
|
+
fill: { position: "absolute", left: 7, top: 7, right: 7, bottom: 7, borderRadius: 30, backgroundColor: "#4f9cff" },
|
|
54
|
+
msg: { position: "absolute", left: 16, right: 16, color: "#fff", fontSize: 16, lineHeight: 24, fontWeight: "500", textAlign: "center" },
|
|
55
|
+
diag: { position: "absolute", left: 16, right: 16, bottom: 88, color: "#9a9a9a", fontSize: 13, lineHeight: 18, textAlign: "center" },
|
|
56
|
+
cancel: { position: "absolute", alignSelf: "center", bottom: 32, height: 40, paddingHorizontal: 18, borderRadius: 999, backgroundColor: "#2a2a2a", justifyContent: "center" },
|
|
57
|
+
cancelText: { color: "#fff", fontSize: 14, fontWeight: "500" },
|
|
58
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { NativeModule } from "expo";
|
|
2
|
+
import type { MagnetFrame, Pt } from "./core/types";
|
|
3
|
+
/** A reading as the native side sends it: the web kit's frame and a little more. */
|
|
4
|
+
export type NativeMagnetFrame = MagnetFrame & {
|
|
5
|
+
/** Unit vector, screen axes, toward the magnet (the bearing without the locator). */
|
|
6
|
+
toward: Pt;
|
|
7
|
+
/** +1 attracts, −1 repels. */
|
|
8
|
+
polarity: number;
|
|
9
|
+
/** Rough distance to the magnet, cm; null when none is near. */
|
|
10
|
+
distance: number | null;
|
|
11
|
+
/** How fast the magnet comes toward the phone, cm/s (negative: away). */
|
|
12
|
+
approachSpeed: number;
|
|
13
|
+
/** The phone has a zero to measure from. */
|
|
14
|
+
zeroed: boolean;
|
|
15
|
+
};
|
|
16
|
+
export type NativeMagnetGesture = {
|
|
17
|
+
g: "tap";
|
|
18
|
+
at: Pt | null;
|
|
19
|
+
} | {
|
|
20
|
+
g: "push" | "pullAway";
|
|
21
|
+
speed: number;
|
|
22
|
+
} | {
|
|
23
|
+
g: "swipe";
|
|
24
|
+
dir: Pt;
|
|
25
|
+
speed: number;
|
|
26
|
+
};
|
|
27
|
+
export type StoredMagnet = {
|
|
28
|
+
face: 1 | -1;
|
|
29
|
+
strength: number;
|
|
30
|
+
sensor: Pt;
|
|
31
|
+
};
|
|
32
|
+
type Events = {
|
|
33
|
+
onFrame(f: NativeMagnetFrame): void;
|
|
34
|
+
onGesture(g: NativeMagnetGesture): void;
|
|
35
|
+
};
|
|
36
|
+
declare class MagnetKitNative extends NativeModule<Events> {
|
|
37
|
+
isAvailable(): boolean;
|
|
38
|
+
hasModel(): boolean;
|
|
39
|
+
start(): Promise<void>;
|
|
40
|
+
stop(): Promise<void>;
|
|
41
|
+
zero(): Promise<void>;
|
|
42
|
+
setMagnet(face: number, strength: number, x: number, y: number): Promise<void>;
|
|
43
|
+
clearMagnet(): Promise<void>;
|
|
44
|
+
getMagnet(): StoredMagnet | null;
|
|
45
|
+
screen(): {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
pointsPerCm: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
declare const _default: MagnetKitNative;
|
|
52
|
+
export default _default;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ComponentRef, type ForwardRefExoticComponent, type ReactNode, type RefAttributes } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import { MagneticField } from "./core/field";
|
|
4
|
+
export type ViewRef = ComponentRef<typeof View>;
|
|
5
|
+
/** The shared field, to tune (stiffness, damping, force, reach, screenCorner…). */
|
|
6
|
+
export declare function getMagneticField(): MagneticField;
|
|
7
|
+
export type MagneticViewProps = ViewProps & {
|
|
8
|
+
/** Corner radius used for collisions; defaults to the style's borderRadius. */
|
|
9
|
+
corner?: number;
|
|
10
|
+
/** Off: the view stays put. */
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
/** A View the magnet pulls. Put several on screen; they collide with each other. */
|
|
15
|
+
export declare const MagneticView: ForwardRefExoticComponent<MagneticViewProps & RefAttributes<ViewRef>>;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/* The Magnetic UI pull for React Native: every <MagneticView> is a small
|
|
3
|
+
rigid body on a stiff damped string to its place, pulled toward a magnet
|
|
4
|
+
held beside the phone; bodies collide, rest against the screen's rounded
|
|
5
|
+
edge, and come back when it leaves. One field for the app, run on the JS
|
|
6
|
+
thread; transforms are set through Animated values, without re-renders. */
|
|
7
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
|
8
|
+
import { AccessibilityInfo, Animated, Dimensions, StyleSheet } from "react-native";
|
|
9
|
+
import { MagneticField } from "./core/field";
|
|
10
|
+
import { Magnet } from "./magnet";
|
|
11
|
+
const bodies = new Map();
|
|
12
|
+
let field = null;
|
|
13
|
+
let raf = 0;
|
|
14
|
+
let offFrames = null;
|
|
15
|
+
let reduceMotion = false;
|
|
16
|
+
let lastMeasure = 0;
|
|
17
|
+
let face = 1;
|
|
18
|
+
let faceAt = -1;
|
|
19
|
+
let nextId = 0;
|
|
20
|
+
AccessibilityInfo.isReduceMotionEnabled?.().then((v) => (reduceMotion = v)).catch(() => { });
|
|
21
|
+
AccessibilityInfo.addEventListener?.("reduceMotionChanged", (v) => (reduceMotion = v));
|
|
22
|
+
/** The shared field, to tune (stiffness, damping, force, reach, screenCorner…). */
|
|
23
|
+
export function getMagneticField() {
|
|
24
|
+
if (!field) {
|
|
25
|
+
field = new MagneticField();
|
|
26
|
+
field.screenCorner = 44;
|
|
27
|
+
}
|
|
28
|
+
return field;
|
|
29
|
+
}
|
|
30
|
+
function measure(id) {
|
|
31
|
+
const b = bodies.get(id);
|
|
32
|
+
const p = field?.parts.get(id);
|
|
33
|
+
// Measure only at rest: the transform would move the box.
|
|
34
|
+
if (!b?.view.current || (p && !p.resting))
|
|
35
|
+
return;
|
|
36
|
+
b.view.current.measureInWindow((x, y, w, h) => {
|
|
37
|
+
if (w > 0 && h > 0)
|
|
38
|
+
b.box = { left: x, top: y, right: x + w, bottom: y + h };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function tick() {
|
|
42
|
+
raf = requestAnimationFrame(tick);
|
|
43
|
+
const f = getMagneticField();
|
|
44
|
+
const now = performance.now() / 1000;
|
|
45
|
+
// Layout moves without telling us (scrolling, keyboard): measure resting bodies now and then.
|
|
46
|
+
if (now - lastMeasure > 0.5 && !f.active) {
|
|
47
|
+
lastMeasure = now;
|
|
48
|
+
bodies.forEach((_, id) => measure(id));
|
|
49
|
+
}
|
|
50
|
+
if (now - faceAt > 1) {
|
|
51
|
+
faceAt = now;
|
|
52
|
+
face = Magnet.getMagnet()?.face ?? 1;
|
|
53
|
+
}
|
|
54
|
+
for (const [id, b] of bodies)
|
|
55
|
+
if (b.box)
|
|
56
|
+
f.setHome(id, b.box, b.corner);
|
|
57
|
+
const frame = Magnet.frame;
|
|
58
|
+
const win = Dimensions.get("window");
|
|
59
|
+
const screenBox = { left: 0, top: 0, right: win.width, bottom: win.height };
|
|
60
|
+
if (frame && !reduceMotion && performance.now() - Magnet.frameAt < 500)
|
|
61
|
+
f.step(frame, now, screenBox, face);
|
|
62
|
+
else
|
|
63
|
+
f.relax(now);
|
|
64
|
+
for (const [id, b] of bodies) {
|
|
65
|
+
const p = f.parts.get(id);
|
|
66
|
+
if (!p)
|
|
67
|
+
continue;
|
|
68
|
+
if (p.resting) {
|
|
69
|
+
b.x.setValue(0);
|
|
70
|
+
b.y.setValue(0);
|
|
71
|
+
b.r.setValue(0);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
b.x.setValue(p.ox);
|
|
75
|
+
b.y.setValue(p.oy);
|
|
76
|
+
b.r.setValue(p.angle);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function register(b) {
|
|
81
|
+
const id = String(nextId++);
|
|
82
|
+
bodies.set(id, b);
|
|
83
|
+
if (bodies.size === 1) {
|
|
84
|
+
offFrames = Magnet.addFrameListener(() => { });
|
|
85
|
+
raf = requestAnimationFrame(tick);
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
remove() {
|
|
90
|
+
bodies.delete(id);
|
|
91
|
+
field?.parts.delete(id);
|
|
92
|
+
if (bodies.size === 0) {
|
|
93
|
+
cancelAnimationFrame(raf);
|
|
94
|
+
offFrames?.();
|
|
95
|
+
offFrames = null;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** A View the magnet pulls. Put several on screen; they collide with each other. */
|
|
101
|
+
export const MagneticView = forwardRef(function MagneticView({ corner, enabled = true, style, onLayout, children, ...rest }, forwarded) {
|
|
102
|
+
const view = useRef(null);
|
|
103
|
+
useImperativeHandle(forwarded, () => view.current);
|
|
104
|
+
const anim = useRef({ x: new Animated.Value(0), y: new Animated.Value(0), r: new Animated.Value(0) }).current;
|
|
105
|
+
const flat = StyleSheet.flatten(style) ?? {};
|
|
106
|
+
const radius = corner ?? (typeof flat.borderRadius === "number" ? flat.borderRadius : 8);
|
|
107
|
+
const reg = useRef(null);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!enabled)
|
|
110
|
+
return;
|
|
111
|
+
const r = register({ view, x: anim.x, y: anim.y, r: anim.r, corner: radius, box: null });
|
|
112
|
+
reg.current = r;
|
|
113
|
+
measure(r.id);
|
|
114
|
+
return () => {
|
|
115
|
+
r.remove();
|
|
116
|
+
reg.current = null;
|
|
117
|
+
anim.x.setValue(0);
|
|
118
|
+
anim.y.setValue(0);
|
|
119
|
+
anim.r.setValue(0);
|
|
120
|
+
};
|
|
121
|
+
}, [enabled, radius, anim]);
|
|
122
|
+
const rotate = anim.r.interpolate({ inputRange: [-1, 1], outputRange: ["-1rad", "1rad"] });
|
|
123
|
+
const own = Array.isArray(flat.transform) ? flat.transform : [];
|
|
124
|
+
return (_jsx(Animated.View, { ref: view, ...rest, style: [style, { transform: [{ translateX: anim.x }, { translateY: anim.y }, { rotate }, ...own] }], onLayout: (e) => {
|
|
125
|
+
if (reg.current)
|
|
126
|
+
measure(reg.current.id);
|
|
127
|
+
onLayout?.(e);
|
|
128
|
+
}, children: children }));
|
|
129
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type CalibrationFit } from "./fit";
|
|
2
|
+
import type { MagnetFrame, Pt } from "./types";
|
|
3
|
+
export type CalibrationState = {
|
|
4
|
+
phase: "zeroing" | "spot" | "done" | "failed" | "cancelled";
|
|
5
|
+
/** Which spot (0-based) and how many. */
|
|
6
|
+
index: number;
|
|
7
|
+
total: number;
|
|
8
|
+
/** Where to draw the ring, in the app's own coordinates (client px, or RN points). */
|
|
9
|
+
spot: Pt | null;
|
|
10
|
+
/** 0…1, how long the magnet has been held still over this spot. */
|
|
11
|
+
progress: number;
|
|
12
|
+
/** The field reported right now, µT; null while no readings arrive. */
|
|
13
|
+
field: number | null;
|
|
14
|
+
/** True while the phone is taking its zero. */
|
|
15
|
+
zeroing: boolean;
|
|
16
|
+
fit: CalibrationFit | null;
|
|
17
|
+
};
|
|
18
|
+
export type CalibrationOptions = {
|
|
19
|
+
/** Spots as fractions of the viewport. Five by default: middle, top, bottom, left, right. */
|
|
20
|
+
spots?: Array<[number, number]>;
|
|
21
|
+
onUpdate?: (s: CalibrationState) => void;
|
|
22
|
+
};
|
|
23
|
+
/** What a calibration needs from where the readings come from. */
|
|
24
|
+
export type CalibrationIO = {
|
|
25
|
+
/** Take a new zero (keep magnets away). */
|
|
26
|
+
zero(): void;
|
|
27
|
+
addFrameListener(l: (f: MagnetFrame) => void): () => void;
|
|
28
|
+
/** Hand the fit to the phone. */
|
|
29
|
+
setMagnet(fit: CalibrationFit): void;
|
|
30
|
+
/** The area the rings are drawn in, in the app's own coordinates. */
|
|
31
|
+
viewport(): Pt;
|
|
32
|
+
/** The app's coordinates onto the screen's, as the readings use. */
|
|
33
|
+
toScreen(p: Pt): Pt;
|
|
34
|
+
/** The screen's size and density, as the readings use. */
|
|
35
|
+
screenSize(): Pt;
|
|
36
|
+
pointsPerCm: number;
|
|
37
|
+
};
|
|
38
|
+
export declare const DEFAULT_SPOTS: Array<[number, number]>;
|
|
39
|
+
/** Runs a calibration; resolves with the fit (already handed to the phone), or null. */
|
|
40
|
+
export declare function runCalibration(io: CalibrationIO, opts?: CalibrationOptions): {
|
|
41
|
+
promise: Promise<CalibrationFit | null>;
|
|
42
|
+
cancel: () => void;
|
|
43
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
import { fitCalibration } from "./fit";
|
|
6
|
+
export const DEFAULT_SPOTS = [[0.5, 0.45], [0.5, 0.12], [0.5, 0.88], [0.15, 0.5], [0.85, 0.5]];
|
|
7
|
+
/** Runs a calibration; resolves with the fit (already handed to the phone), or null. */
|
|
8
|
+
export function runCalibration(io, opts = {}) {
|
|
9
|
+
const SPOTS = opts.spots ?? DEFAULT_SPOTS;
|
|
10
|
+
const spots = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
let hold = [];
|
|
13
|
+
let lastFrame = null;
|
|
14
|
+
let lastFrameAt = 0;
|
|
15
|
+
const zeroUntil = performance.now() + 1500;
|
|
16
|
+
let done = false;
|
|
17
|
+
let resolve;
|
|
18
|
+
const promise = new Promise((r) => (resolve = r));
|
|
19
|
+
const spotAt = (k) => {
|
|
20
|
+
const v = io.viewport();
|
|
21
|
+
return { x: SPOTS[k][0] * v.x, y: SPOTS[k][1] * v.y };
|
|
22
|
+
};
|
|
23
|
+
const state = (phase, progress = 0, fit = null) => ({
|
|
24
|
+
phase, index: i, total: SPOTS.length, spot: phase === "spot" ? spotAt(i) : null, progress,
|
|
25
|
+
field: lastFrame && performance.now() - lastFrameAt < 2000 ? lastFrame.strength : null,
|
|
26
|
+
zeroing: !!lastFrame?.calibrating, fit,
|
|
27
|
+
});
|
|
28
|
+
const emit = (s) => opts.onUpdate?.(s);
|
|
29
|
+
const finish = (phase, fit) => {
|
|
30
|
+
if (done)
|
|
31
|
+
return;
|
|
32
|
+
done = true;
|
|
33
|
+
off();
|
|
34
|
+
clearInterval(tick);
|
|
35
|
+
emit(state(phase, 1, fit));
|
|
36
|
+
resolve(fit);
|
|
37
|
+
};
|
|
38
|
+
io.zero();
|
|
39
|
+
emit(state("zeroing"));
|
|
40
|
+
const tick = setInterval(() => {
|
|
41
|
+
if (!done && performance.now() < zeroUntil)
|
|
42
|
+
emit(state("zeroing"));
|
|
43
|
+
}, 300);
|
|
44
|
+
const off = io.addFrameListener((f) => {
|
|
45
|
+
lastFrame = f;
|
|
46
|
+
lastFrameAt = performance.now();
|
|
47
|
+
if (performance.now() < zeroUntil || f.calibrating) {
|
|
48
|
+
emit(state("zeroing"));
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (f.strength < 15) {
|
|
52
|
+
hold = [];
|
|
53
|
+
emit(state("spot", 0));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
hold.push(f);
|
|
57
|
+
hold = hold.filter((h) => h.t > f.t - 1200);
|
|
58
|
+
const med = (j) => { const v = hold.map((h) => h.delta[j]).sort((a, b) => a - b); return v[v.length >> 1]; };
|
|
59
|
+
const m = [med(0), med(1), med(2)];
|
|
60
|
+
const spread = Math.max(...hold.map((h) => Math.hypot(h.delta[0] - m[0], h.delta[1] - m[1], h.delta[2] - m[2])));
|
|
61
|
+
if (spread > Math.max(4, 0.12 * f.strength)) {
|
|
62
|
+
hold = [f];
|
|
63
|
+
emit(state("spot", 0));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const progress = Math.min(1, (f.t - hold[0].t) / 1000);
|
|
67
|
+
emit(state("spot", progress));
|
|
68
|
+
if (progress < 1)
|
|
69
|
+
return;
|
|
70
|
+
spots.push({ screen: io.toScreen(spotAt(i)), delta: m });
|
|
71
|
+
i++;
|
|
72
|
+
hold = [];
|
|
73
|
+
if (i < SPOTS.length) {
|
|
74
|
+
emit(state("spot", 0));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const fit = fitCalibration(spots, io.screenSize(), io.pointsPerCm);
|
|
78
|
+
if (!fit)
|
|
79
|
+
return finish("failed", null);
|
|
80
|
+
io.setMagnet(fit);
|
|
81
|
+
finish("done", fit);
|
|
82
|
+
});
|
|
83
|
+
return { promise, cancel: () => finish("cancelled", null) };
|
|
84
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { MagnetFrame, Pt } from "./types";
|
|
2
|
+
export type Box = {
|
|
3
|
+
left: number;
|
|
4
|
+
top: number;
|
|
5
|
+
right: number;
|
|
6
|
+
bottom: number;
|
|
7
|
+
};
|
|
8
|
+
export declare class Particle {
|
|
9
|
+
home: Box;
|
|
10
|
+
corner: number;
|
|
11
|
+
ox: number;
|
|
12
|
+
oy: number;
|
|
13
|
+
vx: number;
|
|
14
|
+
vy: number;
|
|
15
|
+
angle: number;
|
|
16
|
+
spin: number;
|
|
17
|
+
atEdge: boolean;
|
|
18
|
+
get mass(): number;
|
|
19
|
+
get inertia(): number;
|
|
20
|
+
get center(): Pt;
|
|
21
|
+
get resting(): boolean;
|
|
22
|
+
}
|
|
23
|
+
export declare class MagneticField {
|
|
24
|
+
parts: Map<string, Particle>;
|
|
25
|
+
magnet: Pt | null;
|
|
26
|
+
overGlass: boolean;
|
|
27
|
+
private lastT;
|
|
28
|
+
private gate;
|
|
29
|
+
private lostAt;
|
|
30
|
+
private strengths;
|
|
31
|
+
screenCorner: number;
|
|
32
|
+
stiffness: number;
|
|
33
|
+
damping: number;
|
|
34
|
+
force: number;
|
|
35
|
+
reach: number;
|
|
36
|
+
maxForce: number;
|
|
37
|
+
standoff: number;
|
|
38
|
+
softening: number;
|
|
39
|
+
nearBand: number;
|
|
40
|
+
farBand: number;
|
|
41
|
+
tiltGain: number;
|
|
42
|
+
maxTilt: number;
|
|
43
|
+
spinStiffness: number;
|
|
44
|
+
spinDamping: number;
|
|
45
|
+
restitution: number;
|
|
46
|
+
wallRestitution: number;
|
|
47
|
+
restingSpeed: number;
|
|
48
|
+
wallFriction: number;
|
|
49
|
+
setHome(id: string, b: Box, corner: number): void;
|
|
50
|
+
get active(): boolean;
|
|
51
|
+
relax(now: number): void;
|
|
52
|
+
step(r: MagnetFrame, now: number, screenBox: Box, face: number): void;
|
|
53
|
+
private outside;
|
|
54
|
+
private nearestEdge;
|
|
55
|
+
private pullPoint;
|
|
56
|
+
private proximity;
|
|
57
|
+
private direction;
|
|
58
|
+
private simulate;
|
|
59
|
+
/** Two boxes (separating axes): push apart by mass; a real knock bounces and spins them. */
|
|
60
|
+
private collide;
|
|
61
|
+
/** Keep a body inside the screen's rounded rectangle. A body whose home is
|
|
62
|
+
* already cut by the screen's edge may rest there, but go no further out. */
|
|
63
|
+
private contain;
|
|
64
|
+
}
|