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,53 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import simd
|
|
3
|
+
|
|
4
|
+
/// The air tap: a magnet hovering over the glass, dipped quickly toward the
|
|
5
|
+
/// screen (2–3 cm) and brought back.
|
|
6
|
+
///
|
|
7
|
+
/// The size of the field alone cannot tell a tap from a sweep across the
|
|
8
|
+
/// sensor or a flick of the magnet — all of them make it jump and fall. The
|
|
9
|
+
/// direction can: in a dip it barely turns, in a sweep it swings round, in a
|
|
10
|
+
/// flick it turns without the distance changing. This small network
|
|
11
|
+
/// (257 → 256 → 256 → 128 → 1, ~165k weights, trained only on simulated
|
|
12
|
+
/// sequences) reads the last 1.28 s of the field and answers how likely it
|
|
13
|
+
/// is that a tap has just finished. The distance itself is never known (2×
|
|
14
|
+
/// farther with an 8× stronger magnet reads the same), so the dip is judged
|
|
15
|
+
/// relative to the height: it has to bring the magnet a good part of the way
|
|
16
|
+
/// to the glass.
|
|
17
|
+
///
|
|
18
|
+
/// `MagnetSource` runs it when `config.tapModel` is on and fires
|
|
19
|
+
/// `.tap(at:)` once per tap, at the located point.
|
|
20
|
+
public final class MagnetTap {
|
|
21
|
+
private let net: MFXLNet
|
|
22
|
+
/// Input layout (must match training): `window` samples at `hz`.
|
|
23
|
+
public let hz: Double
|
|
24
|
+
public let window: Int
|
|
25
|
+
|
|
26
|
+
public init?(data: Data) {
|
|
27
|
+
guard let net = MFXLNet(data: data, kind: "tap") else { return nil }
|
|
28
|
+
hz = net.header["hz"] as? Double ?? 50
|
|
29
|
+
window = net.header["window"] as? Int ?? 64
|
|
30
|
+
guard net.inputs == window * 4 + 1, net.outputs == 1 else { return nil }
|
|
31
|
+
self.net = net
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/// - Parameter history: the field change (µT, device axes, against the
|
|
35
|
+
/// current zero) sampled at `hz`, oldest first, ending now; the last
|
|
36
|
+
/// `window` samples are used.
|
|
37
|
+
/// - Returns: the probability that a tap has just finished (0–1).
|
|
38
|
+
public func evaluate(history h: [SIMD3<Double>]) -> Double {
|
|
39
|
+
guard h.count >= window else { return 0 }
|
|
40
|
+
let w = h.suffix(window)
|
|
41
|
+
let mags = w.map { simd_length($0) }
|
|
42
|
+
let S = max(10, mags.sorted()[(window - 1) / 2]) // the lower median, as torch's
|
|
43
|
+
var x = [Float](); x.reserveCapacity(window * 4 + 1)
|
|
44
|
+
for v in w {
|
|
45
|
+
let s = v / S
|
|
46
|
+
x.append(Float(min(8, max(-8, s.x)))); x.append(Float(min(8, max(-8, s.y)))); x.append(Float(min(8, max(-8, s.z))))
|
|
47
|
+
}
|
|
48
|
+
for m in mags { x.append(Float(min(4, max(-4, log(max(m, 1) / S))))) }
|
|
49
|
+
x.append(Float(log1p(S / 10) / 6))
|
|
50
|
+
let o = net.forward(x)
|
|
51
|
+
return 1 / (1 + exp(-Double(o[0])))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
/// MagnetKit for React Native: runs `MagnetSource` (the magnet model) every
|
|
5
|
+
/// display frame while JavaScript listens, and sends each reading and each
|
|
6
|
+
/// gesture as an event. Positions are window points, as React Native lays
|
|
7
|
+
/// out. The model (Magnet.mfxl) comes from the app bundle.
|
|
8
|
+
public class MagnetKitModule: Module {
|
|
9
|
+
private var ticker: FrameTicker?
|
|
10
|
+
private var gestureId: UUID?
|
|
11
|
+
private static let magnetKey = "MagnetKitRN.magnet"
|
|
12
|
+
|
|
13
|
+
public func definition() -> ModuleDefinition {
|
|
14
|
+
Name("MagnetKit")
|
|
15
|
+
|
|
16
|
+
Events("onFrame", "onGesture")
|
|
17
|
+
|
|
18
|
+
Function("isAvailable") { MagnetSource.shared.isSensorAvailable }
|
|
19
|
+
|
|
20
|
+
Function("hasModel") { MagnetModel.bundled != nil }
|
|
21
|
+
|
|
22
|
+
AsyncFunction("start") { self.start() }.runOnQueue(.main)
|
|
23
|
+
|
|
24
|
+
AsyncFunction("stop") { self.stop() }.runOnQueue(.main)
|
|
25
|
+
|
|
26
|
+
AsyncFunction("zero") { MagnetSource.shared.calibrate() }.runOnQueue(.main)
|
|
27
|
+
|
|
28
|
+
AsyncFunction("setMagnet") { (face: Int, strength: Double, x: Double, y: Double) in
|
|
29
|
+
UserDefaults.standard.set([Double(face), strength, x, y], forKey: Self.magnetKey)
|
|
30
|
+
self.applyMagnet()
|
|
31
|
+
}.runOnQueue(.main)
|
|
32
|
+
|
|
33
|
+
AsyncFunction("clearMagnet") {
|
|
34
|
+
UserDefaults.standard.removeObject(forKey: Self.magnetKey)
|
|
35
|
+
self.applyMagnet()
|
|
36
|
+
}.runOnQueue(.main)
|
|
37
|
+
|
|
38
|
+
Function("getMagnet") { () -> [String: Any]? in
|
|
39
|
+
guard let m = Self.storedMagnet() else { return nil }
|
|
40
|
+
return ["face": m.face, "strength": m.strength, "sensor": ["x": m.sensor.x, "y": m.sensor.y]]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
Function("screen") { () -> [String: Any] in
|
|
44
|
+
let b = UIScreen.main.bounds
|
|
45
|
+
return ["width": b.width, "height": b.height, "pointsPerCm": MagnetSource.shared.config.pointsPerCm]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
OnDestroy {
|
|
49
|
+
DispatchQueue.main.async { self.stop() }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private static func storedMagnet() -> (face: Int, strength: Double, sensor: CGPoint)? {
|
|
54
|
+
guard let a = UserDefaults.standard.array(forKey: magnetKey) as? [Double], a.count == 4 else { return nil }
|
|
55
|
+
return (a[0] < 0 ? -1 : 1, a[1], CGPoint(x: a[2], y: a[3]))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The calibrated magnet, or a guess (sensor near the top centre, face and
|
|
59
|
+
/// strength unknown) so the locator still answers before a calibration.
|
|
60
|
+
private func applyMagnet() {
|
|
61
|
+
let s = MagnetSource.shared
|
|
62
|
+
if let m = Self.storedMagnet() {
|
|
63
|
+
s.config.magnetFace = m.face
|
|
64
|
+
s.config.magnetStrength = m.strength
|
|
65
|
+
s.config.sensorPosition = m.sensor
|
|
66
|
+
} else {
|
|
67
|
+
s.config.magnetFace = nil
|
|
68
|
+
s.config.magnetStrength = nil
|
|
69
|
+
s.config.sensorPosition = CGPoint(x: UIScreen.main.bounds.midX, y: 1.1 * s.config.pointsPerCm)
|
|
70
|
+
}
|
|
71
|
+
s.config.locatorEnabled = true
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private func start() {
|
|
75
|
+
guard ticker == nil else { return }
|
|
76
|
+
let s = MagnetSource.shared
|
|
77
|
+
applyMagnet()
|
|
78
|
+
s.attach()
|
|
79
|
+
gestureId = s.addGestureHandler { [weak self] g in self?.sendEvent("onGesture", Self.gesture(g)) }
|
|
80
|
+
ticker = FrameTicker { [weak self] now in self?.tick(now) }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private func stop() {
|
|
84
|
+
guard let t = ticker else { return }
|
|
85
|
+
t.invalidate()
|
|
86
|
+
ticker = nil
|
|
87
|
+
if let id = gestureId { MagnetSource.shared.removeGestureHandler(id) }
|
|
88
|
+
gestureId = nil
|
|
89
|
+
MagnetSource.shared.detach()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private func tick(_ now: TimeInterval) {
|
|
93
|
+
let s = MagnetSource.shared
|
|
94
|
+
if let scene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene {
|
|
95
|
+
s.orientation = scene.interfaceOrientation
|
|
96
|
+
}
|
|
97
|
+
let r = s.reading(now: now)
|
|
98
|
+
sendEvent("onFrame", [
|
|
99
|
+
"t": now * 1000,
|
|
100
|
+
"pos": r.screenPosition.map { ["x": $0.x, "y": $0.y] },
|
|
101
|
+
"spread": r.screenSpread,
|
|
102
|
+
"height": r.height,
|
|
103
|
+
"presence": r.presence,
|
|
104
|
+
"pull": r.pull,
|
|
105
|
+
"strength": r.strength,
|
|
106
|
+
"delta": [r.delta.x, r.delta.y, r.delta.z],
|
|
107
|
+
"toward": ["x": r.toward.dx, "y": r.toward.dy],
|
|
108
|
+
"polarity": r.polarity,
|
|
109
|
+
"distance": r.distance.isFinite ? r.distance : nil,
|
|
110
|
+
"approachSpeed": r.approachSpeed,
|
|
111
|
+
"tooClose": r.tooClose,
|
|
112
|
+
"calibrating": r.calibrating,
|
|
113
|
+
"zeroed": r.calibrated,
|
|
114
|
+
])
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private static func gesture(_ g: MagnetGesture) -> [String: Any?] {
|
|
118
|
+
switch g {
|
|
119
|
+
case .tap(let at): return ["g": "tap", "at": at.map { ["x": $0.x, "y": $0.y] }]
|
|
120
|
+
case .push(let speed): return ["g": "push", "speed": speed]
|
|
121
|
+
case .pullAway(let speed): return ["g": "pullAway", "speed": speed]
|
|
122
|
+
case .swipe(let d, let speed): return ["g": "swipe", "dir": ["x": d.dx, "y": d.dy], "speed": speed]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// A display link with a closure (CADisplayLink needs an NSObject target).
|
|
128
|
+
private final class FrameTicker: NSObject {
|
|
129
|
+
private var link: CADisplayLink?
|
|
130
|
+
private let onFrame: (TimeInterval) -> Void
|
|
131
|
+
|
|
132
|
+
init(_ onFrame: @escaping (TimeInterval) -> Void) {
|
|
133
|
+
self.onFrame = onFrame
|
|
134
|
+
super.init()
|
|
135
|
+
let l = CADisplayLink(target: self, selector: #selector(step))
|
|
136
|
+
l.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
|
|
137
|
+
l.add(to: .main, forMode: .common)
|
|
138
|
+
link = l
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@objc private func step(_ l: CADisplayLink) { onFrame(l.timestamp) }
|
|
142
|
+
|
|
143
|
+
func invalidate() {
|
|
144
|
+
link?.invalidate()
|
|
145
|
+
link = nil
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'MagnetKitRN'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.license = package['license']
|
|
10
|
+
s.author = package['author']
|
|
11
|
+
s.homepage = package['homepage']
|
|
12
|
+
s.platforms = { :ios => '15.1' }
|
|
13
|
+
s.swift_version = '5.9'
|
|
14
|
+
s.source = { git: 'https://github.com/Jakubantalik/Libraries.dev.git' }
|
|
15
|
+
s.static_framework = true
|
|
16
|
+
|
|
17
|
+
s.dependency 'ExpoModulesCore'
|
|
18
|
+
|
|
19
|
+
s.pod_target_xcconfig = {
|
|
20
|
+
'DEFINES_MODULE' => 'YES',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# MagnetKit's Swift sources (copied in by scripts/sync.mjs) and the module.
|
|
24
|
+
# The model, Magnet.mfxl, is the app's: see the config plugin.
|
|
25
|
+
s.source_files = '*.swift', 'MagnetKit/*.swift'
|
|
26
|
+
end
|
package/package.json
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-native-magnetkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Magnetic UI for React Native: a real magnet beside the phone pulls your interface. MagnetKit (the on-device magnet model) as an Expo module for iOS and Android.",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"build",
|
|
9
|
+
"src",
|
|
10
|
+
"ios",
|
|
11
|
+
"android/build.gradle",
|
|
12
|
+
"android/src",
|
|
13
|
+
"plugin",
|
|
14
|
+
"app.plugin.js",
|
|
15
|
+
"expo-module.config.json",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"sync": "node scripts/sync.mjs",
|
|
21
|
+
"build": "node scripts/sync.mjs && tsc",
|
|
22
|
+
"typecheck": "node scripts/sync.mjs && tsc --noEmit",
|
|
23
|
+
"prepare": "node scripts/sync.mjs",
|
|
24
|
+
"prepack": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"expo": ">=52.0.0",
|
|
28
|
+
"react": ">=18.0.0",
|
|
29
|
+
"react-native": ">=0.76.0"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/react": "~19.3.0",
|
|
33
|
+
"expo": "~58.0.19",
|
|
34
|
+
"react": "19.3.0",
|
|
35
|
+
"react-native": "0.88.0",
|
|
36
|
+
"typescript": "~6.0.3"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"react-native",
|
|
40
|
+
"expo",
|
|
41
|
+
"magnet",
|
|
42
|
+
"magnetometer",
|
|
43
|
+
"magnetic ui",
|
|
44
|
+
"ios",
|
|
45
|
+
"android",
|
|
46
|
+
"gesture"
|
|
47
|
+
],
|
|
48
|
+
"author": "Jakub Antalik",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"homepage": "https://libraries.dev/magnetic",
|
|
51
|
+
"repository": {
|
|
52
|
+
"type": "git",
|
|
53
|
+
"url": "git+https://github.com/Jakubantalik/Libraries.dev.git",
|
|
54
|
+
"directory": "packages/metal-fx/ports/react-native/magnetkit-native"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Expo config plugin: puts the magnet model (Magnet.mfxl, downloaded from
|
|
2
|
+
// libraries.dev under the Libraries.dev Model License) into the app: the iOS
|
|
3
|
+
// app target's resources and Android's assets, where MagnetKit loads it.
|
|
4
|
+
//
|
|
5
|
+
// "plugins": [["react-native-magnetkit", { "model": "./assets/Magnet.mfxl" }]]
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { withDangerousMod, withXcodeProject, IOSConfig } = require("expo/config-plugins");
|
|
10
|
+
|
|
11
|
+
const FILE = "Magnet.mfxl";
|
|
12
|
+
|
|
13
|
+
function modelPath(config, props) {
|
|
14
|
+
const p = path.resolve(config._internal?.projectRoot ?? process.cwd(), props?.model ?? `./assets/${FILE}`);
|
|
15
|
+
if (!fs.existsSync(p)) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`react-native-magnetkit: no model at ${p}. Download Magnet.mfxl from https://libraries.dev/magnetic ` +
|
|
18
|
+
`and point the plugin at it: ["react-native-magnetkit", { "model": "./assets/Magnet.mfxl" }].`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return p;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function withMagnetModelAndroid(config, props) {
|
|
25
|
+
return withDangerousMod(config, [
|
|
26
|
+
"android",
|
|
27
|
+
(c) => {
|
|
28
|
+
const dir = path.join(c.modRequest.platformProjectRoot, "app", "src", "main", "assets");
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
fs.copyFileSync(modelPath(c, props), path.join(dir, FILE));
|
|
31
|
+
return c;
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function withMagnetModelIos(config, props) {
|
|
37
|
+
config = withDangerousMod(config, [
|
|
38
|
+
"ios",
|
|
39
|
+
(c) => {
|
|
40
|
+
const name = IOSConfig.XcodeUtils.getProjectName(c.modRequest.projectRoot);
|
|
41
|
+
fs.copyFileSync(modelPath(c, props), path.join(c.modRequest.platformProjectRoot, name, FILE));
|
|
42
|
+
return c;
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
return withXcodeProject(config, (c) => {
|
|
46
|
+
const project = c.modResults;
|
|
47
|
+
const name = IOSConfig.XcodeUtils.getProjectName(c.modRequest.projectRoot);
|
|
48
|
+
const filepath = path.join(name, FILE);
|
|
49
|
+
if (!project.hasFile(filepath)) {
|
|
50
|
+
IOSConfig.XcodeUtils.addResourceFileToGroup({ filepath, groupName: name, project, isBuildFile: true, verbose: false });
|
|
51
|
+
}
|
|
52
|
+
return c;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = function withMagnetKit(config, props = {}) {
|
|
57
|
+
config = withMagnetModelAndroid(config, props);
|
|
58
|
+
config = withMagnetModelIos(config, props);
|
|
59
|
+
return config;
|
|
60
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/* Calibration: the magnet held still over five rings, fitted to its face,
|
|
2
|
+
strength and where the sensor sits, kept on the phone across launches.
|
|
3
|
+
`calibrate()` runs it headless; <MagnetCalibration> is a ready-made
|
|
4
|
+
full-screen version. */
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { Dimensions, Modal, Pressable, StyleSheet, Text, View } from "react-native";
|
|
8
|
+
import { runCalibration, type CalibrationOptions, type CalibrationState } from "./core/calibration-core";
|
|
9
|
+
import { Magnet } from "./magnet";
|
|
10
|
+
|
|
11
|
+
/** Runs a calibration; resolves with the fit (already kept by the phone), or null. */
|
|
12
|
+
export function calibrate(opts: CalibrationOptions = {}) {
|
|
13
|
+
const screen = Magnet.screen();
|
|
14
|
+
return runCalibration(
|
|
15
|
+
{
|
|
16
|
+
zero: () => { Magnet.zero(); },
|
|
17
|
+
addFrameListener: (l) => Magnet.addFrameListener(l),
|
|
18
|
+
setMagnet: (fit) => { Magnet.setMagnet(fit); },
|
|
19
|
+
viewport: () => {
|
|
20
|
+
const w = Dimensions.get("window");
|
|
21
|
+
return { x: w.width, y: w.height };
|
|
22
|
+
},
|
|
23
|
+
// The window is the screen (edge to edge), as the readings measure it.
|
|
24
|
+
toScreen: (p) => p,
|
|
25
|
+
screenSize: () => ({ x: screen.width, y: screen.height }),
|
|
26
|
+
pointsPerCm: screen.pointsPerCm,
|
|
27
|
+
},
|
|
28
|
+
opts,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MagnetCalibrationProps = {
|
|
33
|
+
visible: boolean;
|
|
34
|
+
/** true when a calibration was kept. */
|
|
35
|
+
onClose?: (ok: boolean) => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A ready-made calibration screen: five rings, full screen, cancel button. */
|
|
39
|
+
export function MagnetCalibration({ visible, onClose }: MagnetCalibrationProps) {
|
|
40
|
+
const [s, setS] = useState<CalibrationState | null>(null);
|
|
41
|
+
const closeRef = useRef(onClose);
|
|
42
|
+
closeRef.current = onClose;
|
|
43
|
+
const cancelRef = useRef<() => void>(() => {});
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!visible) return;
|
|
47
|
+
const run = calibrate({ onUpdate: setS });
|
|
48
|
+
cancelRef.current = run.cancel;
|
|
49
|
+
run.promise.then((fit) => closeRef.current?.(!!fit));
|
|
50
|
+
return () => run.cancel();
|
|
51
|
+
}, [visible]);
|
|
52
|
+
|
|
53
|
+
const open = visible && !!s && s.phase !== "done" && s.phase !== "cancelled";
|
|
54
|
+
const spot = s?.spot ?? null;
|
|
55
|
+
const win = Dimensions.get("window");
|
|
56
|
+
const msg = !s ? "" : s.phase === "zeroing" ? "Keep the magnet away for a moment…"
|
|
57
|
+
: s.phase === "failed" ? "That didn't fit. Try again, holding the magnet still over each ring."
|
|
58
|
+
: `Ring ${s.index + 1} of ${s.total}: hold the magnet 1–2 cm above it and keep it still.`;
|
|
59
|
+
const diag = !s ? "" : s.field == null ? "No readings yet." : `${s.field.toFixed(0)} µT${s.zeroing ? " · zeroing" : ""}`;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<Modal visible={open} transparent animationType="fade" statusBarTranslucent navigationBarTranslucent onRequestClose={() => cancelRef.current()}>
|
|
63
|
+
<View style={styles.root}>
|
|
64
|
+
{spot && (
|
|
65
|
+
<View style={[styles.ring, { left: spot.x - 36, top: spot.y - 36 }]}>
|
|
66
|
+
<View style={[styles.fill, { transform: [{ scale: s?.progress ?? 0 }] }]} />
|
|
67
|
+
</View>
|
|
68
|
+
)}
|
|
69
|
+
<Text style={[styles.msg, { top: spot ? (spot.y < win.height / 2 ? spot.y + 64 : spot.y - 120) : win.height * 0.4 }]}>{msg}</Text>
|
|
70
|
+
<Text style={styles.diag}>{diag}</Text>
|
|
71
|
+
<Pressable onPress={() => cancelRef.current()} style={styles.cancel} accessibilityRole="button">
|
|
72
|
+
<Text style={styles.cancelText}>Cancel</Text>
|
|
73
|
+
</Pressable>
|
|
74
|
+
</View>
|
|
75
|
+
</Modal>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const styles = StyleSheet.create({
|
|
80
|
+
root: { flex: 1, backgroundColor: "rgba(0,0,0,0.94)" },
|
|
81
|
+
ring: { position: "absolute", width: 72, height: 72, borderRadius: 36, borderWidth: 3, borderColor: "#fff" },
|
|
82
|
+
fill: { position: "absolute", left: 7, top: 7, right: 7, bottom: 7, borderRadius: 30, backgroundColor: "#4f9cff" },
|
|
83
|
+
msg: { position: "absolute", left: 16, right: 16, color: "#fff", fontSize: 16, lineHeight: 24, fontWeight: "500", textAlign: "center" },
|
|
84
|
+
diag: { position: "absolute", left: 16, right: 16, bottom: 88, color: "#9a9a9a", fontSize: 13, lineHeight: 18, textAlign: "center" },
|
|
85
|
+
cancel: { position: "absolute", alignSelf: "center", bottom: 32, height: 40, paddingHorizontal: 18, borderRadius: 999, backgroundColor: "#2a2a2a", justifyContent: "center" },
|
|
86
|
+
cancelText: { color: "#fff", fontSize: 14, fontWeight: "500" },
|
|
87
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { NativeModule, requireNativeModule } from "expo";
|
|
2
|
+
import type { MagnetFrame, Pt } from "./core/types";
|
|
3
|
+
|
|
4
|
+
/** A reading as the native side sends it: the web kit's frame and a little more. */
|
|
5
|
+
export type NativeMagnetFrame = MagnetFrame & {
|
|
6
|
+
/** Unit vector, screen axes, toward the magnet (the bearing without the locator). */
|
|
7
|
+
toward: Pt;
|
|
8
|
+
/** +1 attracts, −1 repels. */
|
|
9
|
+
polarity: number;
|
|
10
|
+
/** Rough distance to the magnet, cm; null when none is near. */
|
|
11
|
+
distance: number | null;
|
|
12
|
+
/** How fast the magnet comes toward the phone, cm/s (negative: away). */
|
|
13
|
+
approachSpeed: number;
|
|
14
|
+
/** The phone has a zero to measure from. */
|
|
15
|
+
zeroed: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type NativeMagnetGesture =
|
|
19
|
+
| { g: "tap"; at: Pt | null }
|
|
20
|
+
| { g: "push" | "pullAway"; speed: number }
|
|
21
|
+
| { g: "swipe"; dir: Pt; speed: number };
|
|
22
|
+
|
|
23
|
+
export type StoredMagnet = { face: 1 | -1; strength: number; sensor: Pt };
|
|
24
|
+
|
|
25
|
+
type Events = {
|
|
26
|
+
onFrame(f: NativeMagnetFrame): void;
|
|
27
|
+
onGesture(g: NativeMagnetGesture): void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
declare class MagnetKitNative extends NativeModule<Events> {
|
|
31
|
+
isAvailable(): boolean;
|
|
32
|
+
hasModel(): boolean;
|
|
33
|
+
start(): Promise<void>;
|
|
34
|
+
stop(): Promise<void>;
|
|
35
|
+
zero(): Promise<void>;
|
|
36
|
+
setMagnet(face: number, strength: number, x: number, y: number): Promise<void>;
|
|
37
|
+
clearMagnet(): Promise<void>;
|
|
38
|
+
getMagnet(): StoredMagnet | null;
|
|
39
|
+
screen(): { width: number; height: number; pointsPerCm: number };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default requireNativeModule<MagnetKitNative>("MagnetKit");
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/* The Magnetic UI pull for React Native: every <MagneticView> is a small
|
|
2
|
+
rigid body on a stiff damped string to its place, pulled toward a magnet
|
|
3
|
+
held beside the phone; bodies collide, rest against the screen's rounded
|
|
4
|
+
edge, and come back when it leaves. One field for the app, run on the JS
|
|
5
|
+
thread; transforms are set through Animated values, without re-renders. */
|
|
6
|
+
|
|
7
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, type ComponentRef, type ForwardRefExoticComponent, type ReactNode, type RefAttributes } from "react";
|
|
8
|
+
import { AccessibilityInfo, Animated, Dimensions, StyleSheet, View, type ViewProps } from "react-native";
|
|
9
|
+
import { MagneticField, type Box } from "./core/field";
|
|
10
|
+
import { Magnet } from "./magnet";
|
|
11
|
+
|
|
12
|
+
export type ViewRef = ComponentRef<typeof View>;
|
|
13
|
+
|
|
14
|
+
type Body = {
|
|
15
|
+
view: { current: ViewRef | null };
|
|
16
|
+
x: Animated.Value;
|
|
17
|
+
y: Animated.Value;
|
|
18
|
+
r: Animated.Value;
|
|
19
|
+
corner: number;
|
|
20
|
+
box: Box | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const bodies = new Map<string, Body>();
|
|
24
|
+
let field: MagneticField | null = null;
|
|
25
|
+
let raf = 0;
|
|
26
|
+
let offFrames: (() => void) | null = null;
|
|
27
|
+
let reduceMotion = false;
|
|
28
|
+
let lastMeasure = 0;
|
|
29
|
+
let face = 1;
|
|
30
|
+
let faceAt = -1;
|
|
31
|
+
let nextId = 0;
|
|
32
|
+
|
|
33
|
+
AccessibilityInfo.isReduceMotionEnabled?.().then((v) => (reduceMotion = v)).catch(() => {});
|
|
34
|
+
AccessibilityInfo.addEventListener?.("reduceMotionChanged", (v) => (reduceMotion = v));
|
|
35
|
+
|
|
36
|
+
/** The shared field, to tune (stiffness, damping, force, reach, screenCorner…). */
|
|
37
|
+
export function getMagneticField(): MagneticField {
|
|
38
|
+
if (!field) {
|
|
39
|
+
field = new MagneticField();
|
|
40
|
+
field.screenCorner = 44;
|
|
41
|
+
}
|
|
42
|
+
return field;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function measure(id: string) {
|
|
46
|
+
const b = bodies.get(id);
|
|
47
|
+
const p = field?.parts.get(id);
|
|
48
|
+
// Measure only at rest: the transform would move the box.
|
|
49
|
+
if (!b?.view.current || (p && !p.resting)) return;
|
|
50
|
+
b.view.current.measureInWindow((x, y, w, h) => {
|
|
51
|
+
if (w > 0 && h > 0) b.box = { left: x, top: y, right: x + w, bottom: y + h };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function tick() {
|
|
56
|
+
raf = requestAnimationFrame(tick);
|
|
57
|
+
const f = getMagneticField();
|
|
58
|
+
const now = performance.now() / 1000;
|
|
59
|
+
// Layout moves without telling us (scrolling, keyboard): measure resting bodies now and then.
|
|
60
|
+
if (now - lastMeasure > 0.5 && !f.active) {
|
|
61
|
+
lastMeasure = now;
|
|
62
|
+
bodies.forEach((_, id) => measure(id));
|
|
63
|
+
}
|
|
64
|
+
if (now - faceAt > 1) {
|
|
65
|
+
faceAt = now;
|
|
66
|
+
face = Magnet.getMagnet()?.face ?? 1;
|
|
67
|
+
}
|
|
68
|
+
for (const [id, b] of bodies) if (b.box) f.setHome(id, b.box, b.corner);
|
|
69
|
+
const frame = Magnet.frame;
|
|
70
|
+
const win = Dimensions.get("window");
|
|
71
|
+
const screenBox: Box = { left: 0, top: 0, right: win.width, bottom: win.height };
|
|
72
|
+
if (frame && !reduceMotion && performance.now() - Magnet.frameAt < 500) f.step(frame, now, screenBox, face);
|
|
73
|
+
else f.relax(now);
|
|
74
|
+
for (const [id, b] of bodies) {
|
|
75
|
+
const p = f.parts.get(id);
|
|
76
|
+
if (!p) continue;
|
|
77
|
+
if (p.resting) {
|
|
78
|
+
b.x.setValue(0); b.y.setValue(0); b.r.setValue(0);
|
|
79
|
+
} else {
|
|
80
|
+
b.x.setValue(p.ox); b.y.setValue(p.oy); b.r.setValue(p.angle);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function register(b: Body) {
|
|
86
|
+
const id = String(nextId++);
|
|
87
|
+
bodies.set(id, b);
|
|
88
|
+
if (bodies.size === 1) {
|
|
89
|
+
offFrames = Magnet.addFrameListener(() => {});
|
|
90
|
+
raf = requestAnimationFrame(tick);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
id,
|
|
94
|
+
remove() {
|
|
95
|
+
bodies.delete(id);
|
|
96
|
+
field?.parts.delete(id);
|
|
97
|
+
if (bodies.size === 0) {
|
|
98
|
+
cancelAnimationFrame(raf);
|
|
99
|
+
offFrames?.();
|
|
100
|
+
offFrames = null;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type MagneticViewProps = ViewProps & {
|
|
107
|
+
/** Corner radius used for collisions; defaults to the style's borderRadius. */
|
|
108
|
+
corner?: number;
|
|
109
|
+
/** Off: the view stays put. */
|
|
110
|
+
enabled?: boolean;
|
|
111
|
+
children?: ReactNode;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** A View the magnet pulls. Put several on screen; they collide with each other. */
|
|
115
|
+
export const MagneticView: ForwardRefExoticComponent<MagneticViewProps & RefAttributes<ViewRef>> = forwardRef<ViewRef, MagneticViewProps>(function MagneticView(
|
|
116
|
+
{ corner, enabled = true, style, onLayout, children, ...rest },
|
|
117
|
+
forwarded,
|
|
118
|
+
) {
|
|
119
|
+
const view = useRef<ViewRef | null>(null);
|
|
120
|
+
useImperativeHandle(forwarded, () => view.current as ViewRef);
|
|
121
|
+
const anim = useRef({ x: new Animated.Value(0), y: new Animated.Value(0), r: new Animated.Value(0) }).current;
|
|
122
|
+
const flat = StyleSheet.flatten(style) ?? {};
|
|
123
|
+
const radius = corner ?? (typeof flat.borderRadius === "number" ? flat.borderRadius : 8);
|
|
124
|
+
const reg = useRef<{ id: string; remove(): void } | null>(null);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
if (!enabled) return;
|
|
128
|
+
const r = register({ view, x: anim.x, y: anim.y, r: anim.r, corner: radius, box: null });
|
|
129
|
+
reg.current = r;
|
|
130
|
+
measure(r.id);
|
|
131
|
+
return () => {
|
|
132
|
+
r.remove();
|
|
133
|
+
reg.current = null;
|
|
134
|
+
anim.x.setValue(0); anim.y.setValue(0); anim.r.setValue(0);
|
|
135
|
+
};
|
|
136
|
+
}, [enabled, radius, anim]);
|
|
137
|
+
|
|
138
|
+
const rotate = anim.r.interpolate({ inputRange: [-1, 1], outputRange: ["-1rad", "1rad"] });
|
|
139
|
+
const own = Array.isArray(flat.transform) ? flat.transform : [];
|
|
140
|
+
return (
|
|
141
|
+
<Animated.View
|
|
142
|
+
ref={view}
|
|
143
|
+
{...rest}
|
|
144
|
+
style={[style, { transform: [{ translateX: anim.x }, { translateY: anim.y }, { rotate }, ...own] }]}
|
|
145
|
+
onLayout={(e) => {
|
|
146
|
+
if (reg.current) measure(reg.current.id);
|
|
147
|
+
onLayout?.(e);
|
|
148
|
+
}}
|
|
149
|
+
>
|
|
150
|
+
{children}
|
|
151
|
+
</Animated.View>
|
|
152
|
+
);
|
|
153
|
+
});
|