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 +58 -2
- package/dist/index.cjs.js +1 -0
- package/dist/index.d.ts +272 -0
- package/dist/index.es.js +550 -0
- package/package.json +64 -4
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,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# magnetkit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Magnetic UI for React on the web: a real magnet beside an Android phone
|
|
4
|
+
pulls your interface. Browsers can't read the raw magnetometer, so the
|
|
5
|
+
[Magnet Bridge app](https://libraries.dev/downloads/MagnetBridge.apk) runs
|
|
6
|
+
the on-device magnet model on the phone and serves its answers to the sites
|
|
7
|
+
you allow; this package talks to it and turns the readings into motion.
|
|
8
|
+
|
|
9
|
+
Docs and live demo: https://libraries.dev/magnetic
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install magnetkit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
React 18+. Your site ships no model file.
|
|
18
|
+
|
|
19
|
+
## Use
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { useRef, useState } from "react";
|
|
23
|
+
import { MagnetCalibration, useMagnetBridge, useMagnetGestures, useMagneticPull } from "magnetkit";
|
|
24
|
+
|
|
25
|
+
export function Header() {
|
|
26
|
+
const ref = useRef<HTMLElement>(null);
|
|
27
|
+
const { state, connect, calibrated } = useMagnetBridge();
|
|
28
|
+
const [calibrating, setCalibrating] = useState(false);
|
|
29
|
+
|
|
30
|
+
useMagneticPull(ref); // everything marked data-magnetic is pulled and springs back
|
|
31
|
+
useMagnetGestures((g) => { if (g.g === "tap") openMenu(); });
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<header ref={ref}>
|
|
35
|
+
<a data-magnetic href="/">Home</a>
|
|
36
|
+
<a data-magnetic href="/docs">Docs</a>
|
|
37
|
+
{state !== "open" ? <button onClick={connect}>Connect magnet</button>
|
|
38
|
+
: !calibrated ? <button onClick={() => setCalibrating(true)}>Calibrate</button> : null}
|
|
39
|
+
<MagnetCalibration open={calibrating} onClose={() => setCalibrating(false)} />
|
|
40
|
+
</header>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `useMagnet()` gives the latest reading as state; `useMagnetFrame(cb)` every
|
|
46
|
+
reading without rendering. Positions are screen dp:
|
|
47
|
+
`getMagnetBridge().toClient(p)` turns them into page px.
|
|
48
|
+
- `calibrate(bridge, { onUpdate })` runs a calibration headless.
|
|
49
|
+
- `simulateMagnetWithPointer()` lets the pointer play the magnet while you
|
|
50
|
+
develop without a phone.
|
|
51
|
+
- `MagneticField` is the physics on its own, for canvas or anything else.
|
|
52
|
+
|
|
53
|
+
Visitors need the Magnet Bridge app on an Android phone (Android 10+): they
|
|
54
|
+
tap Start bridge, then connect from your page. Android Chrome asks once to
|
|
55
|
+
allow local network access. iPhone browsers can't take part.
|
|
56
|
+
|
|
57
|
+
## License
|
|
58
|
+
|
|
59
|
+
MIT. The model runs inside the Magnet Bridge app.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const k=require("react/jsx-runtime"),C=require("react"),I=160/2.54;function H(i,t,e,o){const s=Math.hypot(i,t,e),n=-i/s,r=-t/s,l=-e/s,a=o*l,m=1/(s*s*s);return[m*3*a*n,m*3*a*r,m*(3*a*l-o)]}function G(i,t,e=I){if(i.length<3)return null;const o=i.reduce((n,r)=>n+r.delta[0]**2+r.delta[1]**2+r.delta[2]**2,0);let s=null;for(const n of[1,-1])for(let r=0;r<=t.x;r+=8)for(let l=-30;l<=t.y*.5;l+=8)for(let a=.6;a<=5.01;a+=.2){let m=0,d=0;const y=i.map(h=>{const u=H((h.screen.x-r)/e,-(h.screen.y-l)/e,a,n);return m+=u[0]*h.delta[0]+u[1]*h.delta[1]+u[2]*h.delta[2],d+=u[0]*u[0]+u[1]*u[1]+u[2]*u[2],u}),f=m/d;if(!(f>0))continue;let c=0;i.forEach((h,u)=>{const S=y[u];c+=(h.delta[0]-f*S[0])**2+(h.delta[1]-f*S[1])**2+(h.delta[2]-f*S[2])**2}),c/=o,(!s||c<s.error)&&(s={face:n,strength:f,sensor:{x:r,y:l},height:a,error:c})}return s}const U="ws://127.0.0.1:47613";class O{constructor(){this.frame=null,this.hello=null,this.device="",this.version=0,this.state="idle",this.onFrame=null,this.onGesture=null,this.onState=null,this.frameListeners=new Set,this.gestureListeners=new Set,this.stateListeners=new Set,this.ws=null,this.touchOffset=null,addEventListener("touchstart",t=>{const e=t.touches[0];e&&(this.touchOffset={x:e.screenX-e.clientX,y:e.screenY-e.clientY})},{passive:!0})}addFrameListener(t){return this.frameListeners.add(t),()=>{this.frameListeners.delete(t)}}addGestureListener(t){return this.gestureListeners.add(t),()=>{this.gestureListeners.delete(t)}}addStateListener(t){return this.stateListeners.add(t),()=>{this.stateListeners.delete(t)}}zero(){this.send({type:"calibrate"})}setMagnet(t){this.send({type:"set-magnet",face:t.face,strength:t.strength,sensor:[t.sensor.x,t.sensor.y]})}connect(){return this.state==="open"||this.state==="connecting"?Promise.resolve(this.state==="open"):(this.set("connecting"),new Promise(t=>{let e=!1;const o=n=>{e||(e=!0,t(n))};let s;try{s=new WebSocket(U)}catch{return this.set("failed"),o(!1)}this.ws=s,s.onopen=()=>{this.set("open"),o(!0)},s.onclose=()=>{this.ws=null,this.frame=null,this.set("failed"),o(!1)},s.onmessage=n=>this.receive(String(n.data))}))}send(t){this.ws&&this.ws.readyState===WebSocket.OPEN&&this.ws.send(JSON.stringify(t))}offset(){return this.touchOffset??{x:0,y:Math.max(0,screen.height-innerHeight)}}toClient(t){const e=this.offset();return{x:t.x-e.x,y:t.y-e.y}}toScreen(t){const e=this.offset();return{x:t.x+e.x,y:t.y+e.y}}set(t){this.state=t,this.emitState()}emitState(){var t;(t=this.onState)==null||t.call(this),this.stateListeners.forEach(e=>e())}receive(t){var o,s;let e;try{e=JSON.parse(t)}catch{return}if(e.type==="m"){const n={t:e.t,pos:e.pos?{x:e.pos[0],y:e.pos[1]}:null,spread:e.sp??0,height:e.h??null,presence:e.p??1,pull:e.pull??0,strength:e.s??0,delta:e.d??[0,0,0],tooClose:!!e.tc,calibrating:!!e.cal};this.frame=n,(o=this.onFrame)==null||o.call(this,n),this.frameListeners.forEach(r=>r(n))}else if(e.type==="gesture"){const n=e.g==="tap"?{g:"tap",at:e.at?{x:e.at[0],y:e.at[1]}:null}:e;(s=this.onGesture)==null||s.call(this,n),this.gestureListeners.forEach(r=>r(n))}else e.type==="model-hello"?(this.hello=e,this.emitState()):e.type==="hello"&&(this.device=`${e.manufacturer} ${e.model}`,this.version=Number(e.version)||0,this.emitState())}}const P=i=>i.right-i.left,$=i=>i.bottom-i.top,W=(i,t,e)=>({left:i.left+t,top:i.top+e,right:i.right+t,bottom:i.bottom+e}),N=(i,t)=>({left:i.left+t,top:i.top+t,right:i.right-t,bottom:i.bottom-t}),T=(i,t,e)=>Math.min(e,Math.max(t,i));class j{constructor(){this.home={left:0,top:0,right:0,bottom:0},this.corner=8,this.ox=0,this.oy=0,this.vx=0,this.vy=0,this.angle=0,this.spin=0,this.atEdge=!1}get mass(){return Math.max(1,P(this.home)*$(this.home)/1e3)}get inertia(){return this.mass*(P(this.home)**2+$(this.home)**2)/12}get center(){return{x:(this.home.left+this.home.right)/2+this.ox,y:(this.home.top+this.home.bottom)/2+this.oy}}get resting(){return Math.abs(this.ox)<.2&&Math.abs(this.oy)<.2&&Math.abs(this.vx)<2&&Math.abs(this.vy)<2&&Math.abs(this.angle)<.001}}class X{constructor(){this.parts=new Map,this.magnet=null,this.overGlass=!1,this.lastT=0,this.gate=0,this.lostAt=0,this.strengths=[],this.screenCorner=40,this.stiffness=150,this.damping=21,this.force=3e4,this.reach=150,this.maxForce=26e3,this.standoff=40,this.softening=40,this.nearBand=90,this.farBand=320,this.tiltGain=.5,this.maxTilt=.1,this.spinStiffness=80,this.spinDamping=12,this.restitution=.3,this.wallRestitution=.25,this.restingSpeed=250,this.wallFriction=.35}setHome(t,e,o){let s=this.parts.get(t);s||this.parts.set(t,s=new j),s.home=e,s.corner=o}get active(){return this.gate>.01||[...this.parts.values()].some(t=>!t.resting)}relax(t){this.simulate(t,null)}step(t,e,o,s){const n=this.lastT>0?Math.min(.03333333333333333,Math.max(.004166666666666667,e-this.lastT)):.016666666666666666,r=t.pos;if(t.strength>4&&r){const c=this.magnet;if(c){const h=Math.hypot(r.x-c.x,r.y-c.y),u=1-Math.exp(-n/(h>200?.4:.08));this.magnet={x:c.x+(r.x-c.x)*u,y:c.y+(r.y-c.y)*u}}else this.magnet=r;this.lostAt=e}else e-this.lostAt>.15&&(this.magnet=null);for(this.strengths.push([e,t.strength]);this.strengths.length&&e-this.strengths[0][0]>.5;)this.strengths.shift();const l=Math.max(...this.strengths.map(c=>c[1])),a=l>25?T((t.strength/l-.15)/.3,0,1):1,m=(s<0?-1:1)*t.delta[2]/Math.max(1e-6,t.strength),d=this.magnet,y=!!d&&this.outside(d,o)<-25&&(t.height??0)>.8;t.strength>4&&(m>.2||y?this.overGlass=!0:m<0&&(this.overGlass=!1));let f=0;if(d&&!this.overGlass){const c=T((t.strength-10)/25,0,1),h=T((t.presence-.2)/.4,0,1);f=c*h*a}this.gate+=(f-this.gate)*(1-Math.exp(-n/(f>this.gate?.08:.04))),this.simulate(e,o)}outside(t,e){const o=Math.max(e.left-t.x,t.x-e.right),s=Math.max(e.top-t.y,t.y-e.bottom);return o>0||s>0?Math.hypot(Math.max(0,o),Math.max(0,s)):Math.max(o,s)}nearestEdge(t,e){const o=t.x-e.left,s=e.right-t.x,n=t.y-e.top,r=e.bottom-t.y,l=Math.min(o,s,n,r);return l===o?{x:-1,y:0}:l===s?{x:1,y:0}:l===n?{x:0,y:-1}:{x:0,y:1}}pullPoint(t,e){let o=T(t.x,e.left,e.right),s=T(t.y,e.top,e.bottom),n=t.x-o,r=t.y-s;if(Math.hypot(n,r)<1){const a=this.nearestEdge(t,e);a.x?o=a.x<0?e.left:e.right:s=a.y<0?e.top:e.bottom,n=a.x,r=a.y}const l=Math.max(1e-6,Math.hypot(n,r));return{x:o+n/l*this.standoff,y:s+r/l*this.standoff}}proximity(t,e){return T(1-(this.outside(t,e)-this.nearBand)/(this.farBand-this.nearBand),0,1)}direction(t,e,o){let s=e.x-t.x,n=e.y-t.y;const r=Math.max(1e-6,Math.hypot(s,n));s/=r,n/=r;let l=e.x-T(e.x,o.left,o.right),a=e.y-T(e.y,o.top,o.bottom);if(Math.hypot(l,a)<1){const h=this.nearestEdge(e,o);l=h.x,a=h.y}const m=Math.max(1e-6,Math.hypot(l,a));l/=m,a/=m;const d=.65;let y=l*d+s*(1-d),f=a*d+n*(1-d);const c=Math.max(1e-6,Math.hypot(y,f));return{x:y/c,y:f/c}}simulate(t,e){const o=this.lastT>0?Math.min(.03333333333333333,Math.max(.004166666666666667,t-this.lastT)):.016666666666666666;this.lastT=t;for(const n of this.parts.values()){if(P(n.home)<=0)continue;let r=-this.stiffness*n.ox-this.damping*n.vx,l=-this.stiffness*n.oy-this.damping*n.vy,a=0;const m=this.magnet;if(m&&e&&this.gate>.01){const y=this.pullPoint(m,e),f=n.center,c=P(n.home),h=$(n.home),u=W(n.home,n.ox,n.oy),S=T(y.x,u.left,u.right),v=T(y.y,u.top,u.bottom),g=Math.hypot(y.x-S,y.y-v)+this.softening,x=Math.min(this.maxForce,this.force*(this.reach/g)**3)*this.gate*this.proximity(m,e),M=this.direction(f,y,e);r+=x*M.x,l+=x*M.y;const E=Math.cos(n.angle),p=Math.sin(n.angle),b=(y.x-f.x)*E+(y.y-f.y)*p,w=-(y.x-f.x)*p+(y.y-f.y)*E,L=T(b,-c/2,c/2),F=T(w,-h/2,h/2),z=L*E-F*p,R=L*p+F*E;a=this.tiltGain*(z*x*M.y-R*x*M.x)/((c*c+h*h)/12),n.atEdge&&(a*=.4)}n.vx+=r*o,n.vy+=l*o;const d=Math.hypot(n.vx,n.vy);d>2600&&(n.vx=n.vx/d*2600,n.vy=n.vy/d*2600),n.ox+=n.vx*o,n.oy+=n.vy*o,n.spin+=(a-this.spinStiffness*n.angle-this.spinDamping*n.spin)*o,n.angle+=n.spin*o,Math.abs(n.angle)>this.maxTilt&&(n.angle=T(n.angle,-this.maxTilt,this.maxTilt),n.spin*=.4)}if(!e)return;const s=[...this.parts.values()].filter(n=>P(n.home)>0);for(let n=0;n<4;n++){for(let r=0;r<s.length;r++)for(let l=r+1;l<s.length;l++)this.collide(s[r],s[l]);for(const r of s)this.contain(r,e)}for(const n of s)n.angle=T(n.angle,-this.maxTilt,this.maxTilt)}collide(t,e){const o=t.center,s=e.center,n=P(t.home)/2,r=$(t.home)/2,l=P(e.home)/2,a=$(e.home)/2;if(Math.abs(s.x-o.x)>n+l+20||Math.abs(s.y-o.y)>r+a+20)return;const m=[{x:Math.cos(t.angle),y:Math.sin(t.angle)},{x:-Math.sin(t.angle),y:Math.cos(t.angle)}],d=[{x:Math.cos(e.angle),y:Math.sin(e.angle)},{x:-Math.sin(e.angle),y:Math.cos(e.angle)}],y=s.x-o.x,f=s.y-o.y;let c=1/0,h=0,u=0;for(const p of[...m,...d]){const b=n*Math.abs(m[0].x*p.x+m[0].y*p.y)+r*Math.abs(m[1].x*p.x+m[1].y*p.y),w=l*Math.abs(d[0].x*p.x+d[0].y*p.y)+a*Math.abs(d[1].x*p.x+d[1].y*p.y),L=y*p.x+f*p.y,F=b+w-Math.abs(L);if(F<=0)return;F<c&&(c=F,h=L<0?-p.x:p.x,u=L<0?-p.y:p.y)}const S=t.home,v=e.home;if(S.left<v.right-1&&v.left<S.right-1&&S.top<v.bottom-1&&v.top<S.bottom-1)return;const g=1/t.mass,x=1/e.mass,M=c/(g+x);t.ox-=h*M*g,t.oy-=u*M*g,e.ox+=h*M*x,e.oy+=u*M*x;const E=(e.vx-t.vx)*h+(e.vy-t.vy)*u;if(E<0){const p=-E>this.restingSpeed,b=-(1+(p?this.restitution:0))*E/(g+x);if(t.vx-=h*b*g,t.vy-=u*b*g,e.vx+=h*b*x,e.vy+=u*b*x,!p)return;const w=(o.x+s.x)/2,L=(o.y+s.y)/2;t.spin-=.5*((w-o.x)*u-(L-o.y)*h)*b/t.inertia,e.spin+=.5*((w-s.x)*u-(L-s.y)*h)*b/e.inertia}}contain(t,e){const o={left:Math.min(e.left,t.home.left),top:Math.min(e.top,t.home.top),right:Math.max(e.right,t.home.right),bottom:Math.max(e.bottom,t.home.bottom)},s=t.center,n=P(t.home)/2,r=$(t.home)/2,l=Math.min(t.corner,Math.min(n,r)),a=N(o,l),m=Math.max(0,this.screenCorner-l),d=Math.cos(t.angle),y=Math.sin(t.angle);let f=0,c=0,h=0,u=s.x,S=s.y;for(const[E,p]of[[-1,-1],[1,-1],[1,1],[-1,1]]){const b=E*(n-l),w=p*(r-l),L=s.x+b*d-w*y,F=s.y+b*y+w*d,z=_(L,F,a,m),R=Math.hypot(z.x,z.y);R>h&&(h=R,f=z.x,c=z.y,u=L,S=F)}if(h<.5){t.atEdge=!1;return}const v=t.atEdge;t.atEdge=!0,t.ox+=f,t.oy+=c;const g=f/h,x=c/h,M=t.vx*g+t.vy*x;if(M<0){const E=v&&-M<900,p=E?0:-M,b=E?0:this.wallRestitution;t.vx-=(1+b)*M*g,t.vy-=(1+b)*M*x;const w=t.vx*g+t.vy*x,L=t.vx-w*g,F=t.vy-w*x,z=Math.hypot(L,F);if(z>1e-6){const R=E?Math.min(z,this.wallFriction*-M)/z:.15;t.vx-=L*R,t.vy-=F*R}E?t.spin*=.7:t.spin+=.5*((u-s.x)*x-(S-s.y)*g)*p*t.mass/t.inertia}}}function _(i,t,e,o){const s=i<e.left+o?e.left+o:i>e.right-o?e.right-o:null,n=t<e.top+o?e.top+o:t>e.bottom-o?e.bottom-o:null;if(s!=null&&n!=null&&o>0){const a=Math.hypot(i-s,t-n);return a<=o?{x:0,y:0}:{x:(s-i)*(a-o)/a,y:(n-t)*(a-o)/a}}let r=0,l=0;return i<e.left?r=e.left-i:i>e.right&&(r=e.right-i),t<e.top?l=e.top-t:t>e.bottom&&(l=e.bottom-t),{x:r,y:l}}const B=[[.5,.45],[.5,.12],[.5,.88],[.15,.5],[.85,.5]];function Y(i,t={}){const e=t.spots??B,o=[];let s=0,n=[],r=null,l=0;const a=performance.now()+1500;let m=!1,d;const y=new Promise(g=>d=g),f=g=>{const x=i.viewport();return{x:e[g][0]*x.x,y:e[g][1]*x.y}},c=(g,x=0,M=null)=>({phase:g,index:s,total:e.length,spot:g==="spot"?f(s):null,progress:x,field:r&&performance.now()-l<2e3?r.strength:null,zeroing:!!(r!=null&&r.calibrating),fit:M}),h=g=>{var x;return(x=t.onUpdate)==null?void 0:x.call(t,g)},u=(g,x)=>{m||(m=!0,v(),clearInterval(S),h(c(g,1,x)),d(x))};i.zero(),h(c("zeroing"));const S=setInterval(()=>{!m&&performance.now()<a&&h(c("zeroing"))},300),v=i.addFrameListener(g=>{if(r=g,l=performance.now(),performance.now()<a||g.calibrating){h(c("zeroing"));return}if(g.strength<15){n=[],h(c("spot",0));return}n.push(g),n=n.filter(w=>w.t>g.t-1200);const x=w=>{const L=n.map(F=>F.delta[w]).sort((F,z)=>F-z);return L[L.length>>1]},M=[x(0),x(1),x(2)];if(Math.max(...n.map(w=>Math.hypot(w.delta[0]-M[0],w.delta[1]-M[1],w.delta[2]-M[2])))>Math.max(4,.12*g.strength)){n=[g],h(c("spot",0));return}const p=Math.min(1,(g.t-n[0].t)/1e3);if(h(c("spot",p)),p<1)return;if(o.push({screen:i.toScreen(f(s)),delta:M}),s++,n=[],s<e.length){h(c("spot",0));return}const b=G(o,i.screenSize(),i.pointsPerCm);if(!b)return u("failed",null);i.setMagnet(b),u("done",b)});return{promise:y,cancel:()=>u("cancelled",null)}}function D(i,t={}){return Y({zero:()=>i.zero(),addFrameListener:e=>i.addFrameListener(e),setMagnet:e=>i.setMagnet(e),viewport:()=>({x:innerWidth,y:innerHeight}),toScreen:e=>i.toScreen(e),screenSize:()=>{const e=i.hello;return e?{x:e.screenDp[0],y:e.screenDp[1]}:{x:screen.width,y:screen.height}},pointsPerCm:160/2.54},t)}let J=null;function A(){return J??(J=new O)}function K(){var e;const i=A(),t=C.useSyncExternalStore(o=>i.addStateListener(o),()=>{var o,s;return`${i.state}|${i.device}|${((o=i.hello)==null?void 0:o.grabStage)??""}|${((s=i.hello)==null?void 0:s.face)??""}`},()=>"idle||");return{bridge:i,state:i.state,device:i.device,calibrated:((e=i.hello)==null?void 0:e.grabStage)===2,connect:()=>i.connect(),key:t}}function Q(i){const t=C.useRef(i);t.current=i,C.useEffect(()=>A().addFrameListener(e=>t.current(e)),[])}function V(){const[i,t]=C.useState(A().frame);return C.useEffect(()=>{let e=0,o=null;const s=A().addFrameListener(n=>{o=n,e||(e=requestAnimationFrame(()=>{e=0,t(o)}))});return()=>{s(),cancelAnimationFrame(e)}},[]),i}function Z(i){const t=C.useRef(i);t.current=i,C.useEffect(()=>A().addGestureListener(e=>t.current(e)),[])}function q(i,t={}){const{selector:e="[data-magnetic]",enabled:o=!0}=t;C.useEffect(()=>{const s=i.current;if(!s||!o||matchMedia("(prefers-reduced-motion: reduce)").matches)return;const n=A(),r=new X;r.screenCorner=24,t.stiffness!=null&&(r.stiffness=t.stiffness),t.damping!=null&&(r.damping=t.damping),t.force!=null&&(r.force=t.force),t.reach!=null&&(r.reach=t.reach);let l=[];const a=()=>{l.forEach(f=>f.el.style.transform=""),l=[...s.querySelectorAll(e)].filter(f=>f.getBoundingClientRect().width>0).map((f,c)=>{getComputedStyle(f).display==="inline"&&(f.style.display="inline-block");const h=f.getBoundingClientRect();return{id:String(c),el:f,box:{left:h.left+scrollX,top:h.top+scrollY,right:h.right+scrollX,bottom:h.bottom+scrollY},corner:parseFloat(getComputedStyle(f).borderTopLeftRadius)||6}}),r.parts.clear()};a();const m=()=>a();addEventListener("resize",m);let d=0;const y=()=>{var S;const f=performance.now()/1e3,c=n.offset();for(const v of l)r.setHome(v.id,{left:v.box.left-scrollX+c.x,top:v.box.top-scrollY+c.y,right:v.box.right-scrollX+c.x,bottom:v.box.bottom-scrollY+c.y},v.corner);const h=n.frame,u={left:c.x,top:c.y,right:c.x+innerWidth,bottom:c.y+innerHeight};h?r.step(h,f,u,((S=n.hello)==null?void 0:S.face)??1):r.relax(f);for(const v of l){const g=r.parts.get(v.id);g&&(v.el.style.transform=g.resting?"":`translate(${g.ox.toFixed(2)}px, ${g.oy.toFixed(2)}px) rotate(${g.angle.toFixed(4)}rad)`)}d=requestAnimationFrame(y)};return d=requestAnimationFrame(y),()=>{cancelAnimationFrame(d),removeEventListener("resize",m),l.forEach(f=>f.el.style.transform="")}},[i,e,o,t.stiffness,t.damping,t.force,t.reach])}function tt({open:i,onClose:t}){const[e,o]=C.useState(null),s=C.useRef(t);s.current=t;const n=C.useRef(()=>{});if(C.useEffect(()=>{if(!i)return;const m=D(A(),{onUpdate:o});return n.current=m.cancel,m.promise.then(d=>{var y;return(y=s.current)==null?void 0:y.call(s,!!d)}),()=>m.cancel()},[i]),!i||!e||e.phase==="done"||e.phase==="cancelled")return null;const r=e.phase==="zeroing"?"Keep the magnet away for a moment…":e.phase==="failed"?"That didn't fit. Try again, holding the magnet still over each ring.":`Ring ${e.index+1} of ${e.total}: hold the magnet 1–2 cm above it and keep it still.`,l=e.field==null?"No readings from the bridge. Open Magnet Bridge and tap Start bridge.":`${e.field.toFixed(0)} µT${e.zeroing?" · zeroing":""}`,a=e.spot;return k.jsxs("div",{style:{position:"fixed",inset:0,zIndex:2147483e3,background:"rgba(0,0,0,0.94)",color:"#fff",font:"500 16px/24px system-ui, sans-serif",touchAction:"none"},children:[a&&k.jsx("div",{style:{position:"absolute",left:a.x,top:a.y,width:72,height:72,margin:-36,borderRadius:"50%",boxShadow:"inset 0 0 0 3px #fff"},children:k.jsx("i",{style:{position:"absolute",inset:10,borderRadius:"50%",background:"#4f9cff",transform:`scale(${e.progress.toFixed(3)})`}})}),k.jsx("p",{style:{position:"absolute",left:16,right:16,top:a?a.y<innerHeight/2?a.y+64:a.y-120:"40%",margin:0,textAlign:"center"},children:r}),k.jsx("p",{style:{position:"absolute",left:16,right:16,bottom:88,margin:0,textAlign:"center",font:"400 13px/18px system-ui, sans-serif",color:"#9a9a9a"},children:l}),k.jsx("button",{type:"button",onClick:()=>n.current(),style:{position:"absolute",left:"50%",bottom:32,transform:"translateX(-50%)",height:40,padding:"0 18px",border:0,borderRadius:999,background:"#2a2a2a",color:"#fff",font:"500 14px/1 system-ui, sans-serif"},children:"Cancel"})]})}function et(i=A()){let t=!1,e={x:0,y:0};const o=i.offset.bind(i);i.offset=()=>({x:0,y:0});const s=a=>{t=!0,e={x:a.clientX,y:a.clientY}},n=a=>{e={x:a.clientX,y:a.clientY}},r=()=>{t=!1};addEventListener("pointerdown",s),addEventListener("pointermove",n),addEventListener("pointerup",r);const l=window.setInterval(()=>{i.frame={t:performance.now(),pos:t?e:null,spread:20,height:null,presence:1,pull:t?.8:0,strength:t?80:0,delta:t?[0,0,-40]:[0,0,0],tooClose:!1,calibrating:!1}},20);return()=>{clearInterval(l),removeEventListener("pointerdown",s),removeEventListener("pointermove",n),removeEventListener("pointerup",r),i.offset=o}}exports.DEFAULT_SPOTS=B;exports.MagnetBridge=O;exports.MagnetCalibration=tt;exports.MagneticField=X;exports.Particle=j;exports.calibrate=D;exports.fitCalibration=G;exports.getMagnetBridge=A;exports.runCalibration=Y;exports.simulateMagnetWithPointer=et;exports.useMagnet=V;exports.useMagnetBridge=K;exports.useMagnetFrame=Q;exports.useMagnetGestures=Z;exports.useMagneticPull=q;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
|
2
|
+
import { RefObject } from 'react';
|
|
3
|
+
|
|
4
|
+
export declare type Box = {
|
|
5
|
+
left: number;
|
|
6
|
+
top: number;
|
|
7
|
+
right: number;
|
|
8
|
+
bottom: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Runs a calibration; resolves with the fit (already sent to the phone), or null. */
|
|
12
|
+
export declare function calibrate(bridge: MagnetBridge, opts?: CalibrationOptions): {
|
|
13
|
+
promise: Promise<CalibrationFit | null>;
|
|
14
|
+
cancel: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export declare type CalibrationFit = {
|
|
18
|
+
face: 1 | -1;
|
|
19
|
+
strength: number;
|
|
20
|
+
sensor: Pt;
|
|
21
|
+
height: number;
|
|
22
|
+
error: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** What a calibration needs from where the readings come from. */
|
|
26
|
+
export declare type CalibrationIO = {
|
|
27
|
+
/** Take a new zero (keep magnets away). */
|
|
28
|
+
zero(): void;
|
|
29
|
+
addFrameListener(l: (f: MagnetFrame) => void): () => void;
|
|
30
|
+
/** Hand the fit to the phone. */
|
|
31
|
+
setMagnet(fit: CalibrationFit): void;
|
|
32
|
+
/** The area the rings are drawn in, in the app's own coordinates. */
|
|
33
|
+
viewport(): Pt;
|
|
34
|
+
/** The app's coordinates onto the screen's, as the readings use. */
|
|
35
|
+
toScreen(p: Pt): Pt;
|
|
36
|
+
/** The screen's size and density, as the readings use. */
|
|
37
|
+
screenSize(): Pt;
|
|
38
|
+
pointsPerCm: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export declare type CalibrationOptions = {
|
|
42
|
+
/** Spots as fractions of the viewport. Five by default: middle, top, bottom, left, right. */
|
|
43
|
+
spots?: Array<[number, number]>;
|
|
44
|
+
onUpdate?: (s: CalibrationState) => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export declare type CalibrationSpot = {
|
|
48
|
+
screen: Pt;
|
|
49
|
+
delta: [number, number, number];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export declare type CalibrationState = {
|
|
53
|
+
phase: "zeroing" | "spot" | "done" | "failed" | "cancelled";
|
|
54
|
+
/** Which spot (0-based) and how many. */
|
|
55
|
+
index: number;
|
|
56
|
+
total: number;
|
|
57
|
+
/** Where to draw the ring, in the app's own coordinates (client px, or RN points). */
|
|
58
|
+
spot: Pt | null;
|
|
59
|
+
/** 0…1, how long the magnet has been held still over this spot. */
|
|
60
|
+
progress: number;
|
|
61
|
+
/** The field reported right now, µT; null while no readings arrive. */
|
|
62
|
+
field: number | null;
|
|
63
|
+
/** True while the phone is taking its zero. */
|
|
64
|
+
zeroing: boolean;
|
|
65
|
+
fit: CalibrationFit | null;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export declare const DEFAULT_SPOTS: Array<[number, number]>;
|
|
69
|
+
|
|
70
|
+
export declare function fitCalibration(spots: CalibrationSpot[], screenDp: Pt, PPCM?: number): CalibrationFit | null;
|
|
71
|
+
|
|
72
|
+
/** The page's bridge. Nothing connects until you call `connect()`. */
|
|
73
|
+
export declare function getMagnetBridge(): MagnetBridge;
|
|
74
|
+
|
|
75
|
+
export declare class MagnetBridge {
|
|
76
|
+
frame: MagnetFrame | null;
|
|
77
|
+
hello: ModelHello | null;
|
|
78
|
+
device: string;
|
|
79
|
+
/** The app's protocol version (0.5 sends to each page on its own). */
|
|
80
|
+
version: number;
|
|
81
|
+
state: "idle" | "connecting" | "open" | "failed";
|
|
82
|
+
onFrame: ((f: MagnetFrame) => void) | null;
|
|
83
|
+
onGesture: ((g: MagnetGesture) => void) | null;
|
|
84
|
+
onState: (() => void) | null;
|
|
85
|
+
private frameListeners;
|
|
86
|
+
private gestureListeners;
|
|
87
|
+
private stateListeners;
|
|
88
|
+
/** Every reading (~50 a second). Returns an unsubscribe function. */
|
|
89
|
+
addFrameListener(l: (f: MagnetFrame) => void): () => void;
|
|
90
|
+
/** Air taps, pushes, pull-aways and swipes. Returns an unsubscribe function. */
|
|
91
|
+
addGestureListener(l: (g: MagnetGesture) => void): () => void;
|
|
92
|
+
/** Connection state, device and calibration changes. Returns an unsubscribe function. */
|
|
93
|
+
addStateListener(l: () => void): () => void;
|
|
94
|
+
/** Take a new zero on the phone (keep magnets away for a second). */
|
|
95
|
+
zero(): void;
|
|
96
|
+
/** Hand the phone a fitted calibration (see fitCalibration). */
|
|
97
|
+
setMagnet(fit: {
|
|
98
|
+
face: 1 | -1;
|
|
99
|
+
strength: number;
|
|
100
|
+
sensor: Pt;
|
|
101
|
+
}): void;
|
|
102
|
+
private ws;
|
|
103
|
+
private touchOffset;
|
|
104
|
+
constructor();
|
|
105
|
+
connect(): Promise<boolean>;
|
|
106
|
+
send(obj: unknown): void;
|
|
107
|
+
/** Screen dp → client px. The viewport runs to the bottom of the screen on
|
|
108
|
+
* Android Chrome, so the bars above it are what is left of the screen. */
|
|
109
|
+
offset(): Pt;
|
|
110
|
+
toClient(p: Pt): Pt;
|
|
111
|
+
toScreen(p: Pt): Pt;
|
|
112
|
+
private set;
|
|
113
|
+
private emitState;
|
|
114
|
+
private receive;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A ready-made calibration screen: five rings, full screen, cancel button. */
|
|
118
|
+
export declare function MagnetCalibration({ open, onClose }: {
|
|
119
|
+
open: boolean;
|
|
120
|
+
onClose?: (ok: boolean) => void;
|
|
121
|
+
}): JSX_2.Element | null;
|
|
122
|
+
|
|
123
|
+
export declare type MagnetFrame = {
|
|
124
|
+
t: number;
|
|
125
|
+
/** Where the magnet is, screen dp (null while the locator is unsure). */
|
|
126
|
+
pos: Pt | null;
|
|
127
|
+
spread: number;
|
|
128
|
+
height: number | null;
|
|
129
|
+
presence: number;
|
|
130
|
+
pull: number;
|
|
131
|
+
strength: number;
|
|
132
|
+
/** Field change from the zero, µT, device axes (x right, y up, z out of the screen). */
|
|
133
|
+
delta: [number, number, number];
|
|
134
|
+
tooClose: boolean;
|
|
135
|
+
calibrating: boolean;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export declare type MagnetGesture = {
|
|
139
|
+
g: "tap";
|
|
140
|
+
at: Pt | null;
|
|
141
|
+
} | {
|
|
142
|
+
g: "push" | "pullAway";
|
|
143
|
+
speed: number;
|
|
144
|
+
} | {
|
|
145
|
+
g: "swipe";
|
|
146
|
+
dir: Pt;
|
|
147
|
+
speed: number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export declare class MagneticField {
|
|
151
|
+
parts: Map<string, Particle>;
|
|
152
|
+
magnet: Pt | null;
|
|
153
|
+
overGlass: boolean;
|
|
154
|
+
private lastT;
|
|
155
|
+
private gate;
|
|
156
|
+
private lostAt;
|
|
157
|
+
private strengths;
|
|
158
|
+
screenCorner: number;
|
|
159
|
+
stiffness: number;
|
|
160
|
+
damping: number;
|
|
161
|
+
force: number;
|
|
162
|
+
reach: number;
|
|
163
|
+
maxForce: number;
|
|
164
|
+
standoff: number;
|
|
165
|
+
softening: number;
|
|
166
|
+
nearBand: number;
|
|
167
|
+
farBand: number;
|
|
168
|
+
tiltGain: number;
|
|
169
|
+
maxTilt: number;
|
|
170
|
+
spinStiffness: number;
|
|
171
|
+
spinDamping: number;
|
|
172
|
+
restitution: number;
|
|
173
|
+
wallRestitution: number;
|
|
174
|
+
restingSpeed: number;
|
|
175
|
+
wallFriction: number;
|
|
176
|
+
setHome(id: string, b: Box, corner: number): void;
|
|
177
|
+
get active(): boolean;
|
|
178
|
+
relax(now: number): void;
|
|
179
|
+
step(r: MagnetFrame, now: number, screenBox: Box, face: number): void;
|
|
180
|
+
private outside;
|
|
181
|
+
private nearestEdge;
|
|
182
|
+
private pullPoint;
|
|
183
|
+
private proximity;
|
|
184
|
+
private direction;
|
|
185
|
+
private simulate;
|
|
186
|
+
/** Two boxes (separating axes): push apart by mass; a real knock bounces and spins them. */
|
|
187
|
+
private collide;
|
|
188
|
+
/** Keep a body inside the screen's rounded rectangle. A body whose home is
|
|
189
|
+
* already cut by the screen's edge may rest there, but go no further out. */
|
|
190
|
+
private contain;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export declare type MagneticPullOptions = {
|
|
194
|
+
/** Which elements inside the container move. Default `[data-magnetic]`. */
|
|
195
|
+
selector?: string;
|
|
196
|
+
enabled?: boolean;
|
|
197
|
+
/** Tuning, as MagneticField's: spring stiffness, damping, force, reach (px). */
|
|
198
|
+
stiffness?: number;
|
|
199
|
+
damping?: number;
|
|
200
|
+
force?: number;
|
|
201
|
+
reach?: number;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export declare type ModelHello = {
|
|
205
|
+
screenDp: [number, number];
|
|
206
|
+
grabStage: number;
|
|
207
|
+
face: number | null;
|
|
208
|
+
strength: number | null;
|
|
209
|
+
sensorDp: [number, number];
|
|
210
|
+
model: boolean;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export declare class Particle {
|
|
214
|
+
home: Box;
|
|
215
|
+
corner: number;
|
|
216
|
+
ox: number;
|
|
217
|
+
oy: number;
|
|
218
|
+
vx: number;
|
|
219
|
+
vy: number;
|
|
220
|
+
angle: number;
|
|
221
|
+
spin: number;
|
|
222
|
+
atEdge: boolean;
|
|
223
|
+
get mass(): number;
|
|
224
|
+
get inertia(): number;
|
|
225
|
+
get center(): Pt;
|
|
226
|
+
get resting(): boolean;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export declare type Pt = {
|
|
230
|
+
x: number;
|
|
231
|
+
y: number;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Runs a calibration; resolves with the fit (already handed to the phone), or null. */
|
|
235
|
+
export declare function runCalibration(io: CalibrationIO, opts?: CalibrationOptions): {
|
|
236
|
+
promise: Promise<CalibrationFit | null>;
|
|
237
|
+
cancel: () => void;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Desktop development without a phone: the pointer plays the magnet. Press
|
|
242
|
+
* and hold to bring it in, release to take it away. Returns a stop function.
|
|
243
|
+
*/
|
|
244
|
+
export declare function simulateMagnetWithPointer(bridge?: MagnetBridge): () => void;
|
|
245
|
+
|
|
246
|
+
/** The latest reading as state, at most one render per animation frame. */
|
|
247
|
+
export declare function useMagnet(): MagnetFrame | null;
|
|
248
|
+
|
|
249
|
+
/** Connection state, device and calibration; re-renders when they change. */
|
|
250
|
+
export declare function useMagnetBridge(): {
|
|
251
|
+
bridge: MagnetBridge;
|
|
252
|
+
state: "idle" | "connecting" | "open" | "failed";
|
|
253
|
+
device: string;
|
|
254
|
+
calibrated: boolean;
|
|
255
|
+
connect: () => Promise<boolean>;
|
|
256
|
+
key: string;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/** Every reading, without re-rendering (~50 a second). */
|
|
260
|
+
export declare function useMagnetFrame(cb: (f: MagnetFrame) => void): void;
|
|
261
|
+
|
|
262
|
+
/** Air taps, pushes, pull-aways and swipes. */
|
|
263
|
+
export declare function useMagnetGestures(cb: (g: MagnetGesture) => void): void;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* The Magnetic UI pull: elements matching `selector` inside the container
|
|
267
|
+
* are springs to their place, pulled toward a magnet held beside the phone,
|
|
268
|
+
* and they come back when it leaves. Off with reduced motion.
|
|
269
|
+
*/
|
|
270
|
+
export declare function useMagneticPull(container: RefObject<HTMLElement>, opts?: MagneticPullOptions): void;
|
|
271
|
+
|
|
272
|
+
export { }
|
package/dist/index.es.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { jsxs as X, jsx as $ } from "react/jsx-runtime";
|
|
2
|
+
import { useState as O, useRef as G, useEffect as P, useSyncExternalStore as Y } from "react";
|
|
3
|
+
const I = 160 / 2.54;
|
|
4
|
+
function D(i, t, e, o) {
|
|
5
|
+
const s = Math.hypot(i, t, e), n = -i / s, r = -t / s, h = -e / s, a = o * h, u = 1 / (s * s * s);
|
|
6
|
+
return [u * 3 * a * n, u * 3 * a * r, u * (3 * a * h - o)];
|
|
7
|
+
}
|
|
8
|
+
function H(i, t, e = I) {
|
|
9
|
+
if (i.length < 3) return null;
|
|
10
|
+
const o = i.reduce((n, r) => n + r.delta[0] ** 2 + r.delta[1] ** 2 + r.delta[2] ** 2, 0);
|
|
11
|
+
let s = null;
|
|
12
|
+
for (const n of [1, -1])
|
|
13
|
+
for (let r = 0; r <= t.x; r += 8)
|
|
14
|
+
for (let h = -30; h <= t.y * 0.5; h += 8)
|
|
15
|
+
for (let a = 0.6; a <= 5.01; a += 0.2) {
|
|
16
|
+
let u = 0, d = 0;
|
|
17
|
+
const y = i.map((l) => {
|
|
18
|
+
const g = D((l.screen.x - r) / e, -(l.screen.y - h) / e, a, n);
|
|
19
|
+
return u += g[0] * l.delta[0] + g[1] * l.delta[1] + g[2] * l.delta[2], d += g[0] * g[0] + g[1] * g[1] + g[2] * g[2], g;
|
|
20
|
+
}), f = u / d;
|
|
21
|
+
if (!(f > 0)) continue;
|
|
22
|
+
let c = 0;
|
|
23
|
+
i.forEach((l, g) => {
|
|
24
|
+
const S = y[g];
|
|
25
|
+
c += (l.delta[0] - f * S[0]) ** 2 + (l.delta[1] - f * S[1]) ** 2 + (l.delta[2] - f * S[2]) ** 2;
|
|
26
|
+
}), c /= o, (!s || c < s.error) && (s = { face: n, strength: f, sensor: { x: r, y: h }, height: a, error: c });
|
|
27
|
+
}
|
|
28
|
+
return s;
|
|
29
|
+
}
|
|
30
|
+
const j = "ws://127.0.0.1:47613";
|
|
31
|
+
class B {
|
|
32
|
+
constructor() {
|
|
33
|
+
this.frame = null, this.hello = null, this.device = "", this.version = 0, this.state = "idle", this.onFrame = null, this.onGesture = null, this.onState = null, this.frameListeners = /* @__PURE__ */ new Set(), this.gestureListeners = /* @__PURE__ */ new Set(), this.stateListeners = /* @__PURE__ */ new Set(), this.ws = null, this.touchOffset = null, addEventListener(
|
|
34
|
+
"touchstart",
|
|
35
|
+
(t) => {
|
|
36
|
+
const e = t.touches[0];
|
|
37
|
+
e && (this.touchOffset = { x: e.screenX - e.clientX, y: e.screenY - e.clientY });
|
|
38
|
+
},
|
|
39
|
+
{ passive: !0 }
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
/** Every reading (~50 a second). Returns an unsubscribe function. */
|
|
43
|
+
addFrameListener(t) {
|
|
44
|
+
return this.frameListeners.add(t), () => {
|
|
45
|
+
this.frameListeners.delete(t);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Air taps, pushes, pull-aways and swipes. Returns an unsubscribe function. */
|
|
49
|
+
addGestureListener(t) {
|
|
50
|
+
return this.gestureListeners.add(t), () => {
|
|
51
|
+
this.gestureListeners.delete(t);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Connection state, device and calibration changes. Returns an unsubscribe function. */
|
|
55
|
+
addStateListener(t) {
|
|
56
|
+
return this.stateListeners.add(t), () => {
|
|
57
|
+
this.stateListeners.delete(t);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** Take a new zero on the phone (keep magnets away for a second). */
|
|
61
|
+
zero() {
|
|
62
|
+
this.send({ type: "calibrate" });
|
|
63
|
+
}
|
|
64
|
+
/** Hand the phone a fitted calibration (see fitCalibration). */
|
|
65
|
+
setMagnet(t) {
|
|
66
|
+
this.send({ type: "set-magnet", face: t.face, strength: t.strength, sensor: [t.sensor.x, t.sensor.y] });
|
|
67
|
+
}
|
|
68
|
+
connect() {
|
|
69
|
+
return this.state === "open" || this.state === "connecting" ? Promise.resolve(this.state === "open") : (this.set("connecting"), new Promise((t) => {
|
|
70
|
+
let e = !1;
|
|
71
|
+
const o = (n) => {
|
|
72
|
+
e || (e = !0, t(n));
|
|
73
|
+
};
|
|
74
|
+
let s;
|
|
75
|
+
try {
|
|
76
|
+
s = new WebSocket(j);
|
|
77
|
+
} catch {
|
|
78
|
+
return this.set("failed"), o(!1);
|
|
79
|
+
}
|
|
80
|
+
this.ws = s, s.onopen = () => {
|
|
81
|
+
this.set("open"), o(!0);
|
|
82
|
+
}, s.onclose = () => {
|
|
83
|
+
this.ws = null, this.frame = null, this.set("failed"), o(!1);
|
|
84
|
+
}, s.onmessage = (n) => this.receive(String(n.data));
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
send(t) {
|
|
88
|
+
this.ws && this.ws.readyState === WebSocket.OPEN && this.ws.send(JSON.stringify(t));
|
|
89
|
+
}
|
|
90
|
+
/** Screen dp → client px. The viewport runs to the bottom of the screen on
|
|
91
|
+
* Android Chrome, so the bars above it are what is left of the screen. */
|
|
92
|
+
offset() {
|
|
93
|
+
return this.touchOffset ?? { x: 0, y: Math.max(0, screen.height - innerHeight) };
|
|
94
|
+
}
|
|
95
|
+
toClient(t) {
|
|
96
|
+
const e = this.offset();
|
|
97
|
+
return { x: t.x - e.x, y: t.y - e.y };
|
|
98
|
+
}
|
|
99
|
+
toScreen(t) {
|
|
100
|
+
const e = this.offset();
|
|
101
|
+
return { x: t.x + e.x, y: t.y + e.y };
|
|
102
|
+
}
|
|
103
|
+
set(t) {
|
|
104
|
+
this.state = t, this.emitState();
|
|
105
|
+
}
|
|
106
|
+
emitState() {
|
|
107
|
+
var t;
|
|
108
|
+
(t = this.onState) == null || t.call(this), this.stateListeners.forEach((e) => e());
|
|
109
|
+
}
|
|
110
|
+
receive(t) {
|
|
111
|
+
var o, s;
|
|
112
|
+
let e;
|
|
113
|
+
try {
|
|
114
|
+
e = JSON.parse(t);
|
|
115
|
+
} catch {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (e.type === "m") {
|
|
119
|
+
const n = {
|
|
120
|
+
t: e.t,
|
|
121
|
+
pos: e.pos ? { x: e.pos[0], y: e.pos[1] } : null,
|
|
122
|
+
spread: e.sp ?? 0,
|
|
123
|
+
height: e.h ?? null,
|
|
124
|
+
presence: e.p ?? 1,
|
|
125
|
+
pull: e.pull ?? 0,
|
|
126
|
+
strength: e.s ?? 0,
|
|
127
|
+
delta: e.d ?? [0, 0, 0],
|
|
128
|
+
tooClose: !!e.tc,
|
|
129
|
+
calibrating: !!e.cal
|
|
130
|
+
};
|
|
131
|
+
this.frame = n, (o = this.onFrame) == null || o.call(this, n), this.frameListeners.forEach((r) => r(n));
|
|
132
|
+
} else if (e.type === "gesture") {
|
|
133
|
+
const n = e.g === "tap" ? { g: "tap", at: e.at ? { x: e.at[0], y: e.at[1] } : null } : e;
|
|
134
|
+
(s = this.onGesture) == null || s.call(this, n), this.gestureListeners.forEach((r) => r(n));
|
|
135
|
+
} else e.type === "model-hello" ? (this.hello = e, this.emitState()) : e.type === "hello" && (this.device = `${e.manufacturer} ${e.model}`, this.version = Number(e.version) || 0, this.emitState());
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const k = (i) => i.right - i.left, R = (i) => i.bottom - i.top, N = (i, t, e) => ({ left: i.left + t, top: i.top + e, right: i.right + t, bottom: i.bottom + e }), U = (i, t) => ({ left: i.left + t, top: i.top + t, right: i.right - t, bottom: i.bottom - t }), z = (i, t, e) => Math.min(e, Math.max(t, i));
|
|
139
|
+
class W {
|
|
140
|
+
constructor() {
|
|
141
|
+
this.home = { left: 0, top: 0, right: 0, bottom: 0 }, this.corner = 8, this.ox = 0, this.oy = 0, this.vx = 0, this.vy = 0, this.angle = 0, this.spin = 0, this.atEdge = !1;
|
|
142
|
+
}
|
|
143
|
+
get mass() {
|
|
144
|
+
return Math.max(1, k(this.home) * R(this.home) / 1e3);
|
|
145
|
+
}
|
|
146
|
+
get inertia() {
|
|
147
|
+
return this.mass * (k(this.home) ** 2 + R(this.home) ** 2) / 12;
|
|
148
|
+
}
|
|
149
|
+
get center() {
|
|
150
|
+
return { x: (this.home.left + this.home.right) / 2 + this.ox, y: (this.home.top + this.home.bottom) / 2 + this.oy };
|
|
151
|
+
}
|
|
152
|
+
get resting() {
|
|
153
|
+
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) < 1e-3;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
class _ {
|
|
157
|
+
constructor() {
|
|
158
|
+
this.parts = /* @__PURE__ */ new Map(), this.magnet = null, this.overGlass = !1, this.lastT = 0, this.gate = 0, this.lostAt = 0, this.strengths = [], this.screenCorner = 40, this.stiffness = 150, this.damping = 21, this.force = 3e4, this.reach = 150, this.maxForce = 26e3, this.standoff = 40, this.softening = 40, this.nearBand = 90, this.farBand = 320, this.tiltGain = 0.5, this.maxTilt = 0.1, this.spinStiffness = 80, this.spinDamping = 12, this.restitution = 0.3, this.wallRestitution = 0.25, this.restingSpeed = 250, this.wallFriction = 0.35;
|
|
159
|
+
}
|
|
160
|
+
setHome(t, e, o) {
|
|
161
|
+
let s = this.parts.get(t);
|
|
162
|
+
s || this.parts.set(t, s = new W()), s.home = e, s.corner = o;
|
|
163
|
+
}
|
|
164
|
+
get active() {
|
|
165
|
+
return this.gate > 0.01 || [...this.parts.values()].some((t) => !t.resting);
|
|
166
|
+
}
|
|
167
|
+
relax(t) {
|
|
168
|
+
this.simulate(t, null);
|
|
169
|
+
}
|
|
170
|
+
step(t, e, o, s) {
|
|
171
|
+
const n = this.lastT > 0 ? Math.min(0.03333333333333333, Math.max(0.004166666666666667, e - this.lastT)) : 0.016666666666666666, r = t.pos;
|
|
172
|
+
if (t.strength > 4 && r) {
|
|
173
|
+
const c = this.magnet;
|
|
174
|
+
if (c) {
|
|
175
|
+
const l = Math.hypot(r.x - c.x, r.y - c.y), g = 1 - Math.exp(-n / (l > 200 ? 0.4 : 0.08));
|
|
176
|
+
this.magnet = { x: c.x + (r.x - c.x) * g, y: c.y + (r.y - c.y) * g };
|
|
177
|
+
} else this.magnet = r;
|
|
178
|
+
this.lostAt = e;
|
|
179
|
+
} else e - this.lostAt > 0.15 && (this.magnet = null);
|
|
180
|
+
for (this.strengths.push([e, t.strength]); this.strengths.length && e - this.strengths[0][0] > 0.5; ) this.strengths.shift();
|
|
181
|
+
const h = Math.max(...this.strengths.map((c) => c[1])), a = h > 25 ? z((t.strength / h - 0.15) / 0.3, 0, 1) : 1, u = (s < 0 ? -1 : 1) * t.delta[2] / Math.max(1e-6, t.strength), d = this.magnet, y = !!d && this.outside(d, o) < -25 && (t.height ?? 0) > 0.8;
|
|
182
|
+
t.strength > 4 && (u > 0.2 || y ? this.overGlass = !0 : u < 0 && (this.overGlass = !1));
|
|
183
|
+
let f = 0;
|
|
184
|
+
if (d && !this.overGlass) {
|
|
185
|
+
const c = z((t.strength - 10) / 25, 0, 1), l = z((t.presence - 0.2) / 0.4, 0, 1);
|
|
186
|
+
f = c * l * a;
|
|
187
|
+
}
|
|
188
|
+
this.gate += (f - this.gate) * (1 - Math.exp(-n / (f > this.gate ? 0.08 : 0.04))), this.simulate(e, o);
|
|
189
|
+
}
|
|
190
|
+
outside(t, e) {
|
|
191
|
+
const o = Math.max(e.left - t.x, t.x - e.right), s = Math.max(e.top - t.y, t.y - e.bottom);
|
|
192
|
+
return o > 0 || s > 0 ? Math.hypot(Math.max(0, o), Math.max(0, s)) : Math.max(o, s);
|
|
193
|
+
}
|
|
194
|
+
nearestEdge(t, e) {
|
|
195
|
+
const o = t.x - e.left, s = e.right - t.x, n = t.y - e.top, r = e.bottom - t.y, h = Math.min(o, s, n, r);
|
|
196
|
+
return h === o ? { x: -1, y: 0 } : h === s ? { x: 1, y: 0 } : h === n ? { x: 0, y: -1 } : { x: 0, y: 1 };
|
|
197
|
+
}
|
|
198
|
+
pullPoint(t, e) {
|
|
199
|
+
let o = z(t.x, e.left, e.right), s = z(t.y, e.top, e.bottom), n = t.x - o, r = t.y - s;
|
|
200
|
+
if (Math.hypot(n, r) < 1) {
|
|
201
|
+
const a = this.nearestEdge(t, e);
|
|
202
|
+
a.x ? o = a.x < 0 ? e.left : e.right : s = a.y < 0 ? e.top : e.bottom, n = a.x, r = a.y;
|
|
203
|
+
}
|
|
204
|
+
const h = Math.max(1e-6, Math.hypot(n, r));
|
|
205
|
+
return { x: o + n / h * this.standoff, y: s + r / h * this.standoff };
|
|
206
|
+
}
|
|
207
|
+
proximity(t, e) {
|
|
208
|
+
return z(1 - (this.outside(t, e) - this.nearBand) / (this.farBand - this.nearBand), 0, 1);
|
|
209
|
+
}
|
|
210
|
+
direction(t, e, o) {
|
|
211
|
+
let s = e.x - t.x, n = e.y - t.y;
|
|
212
|
+
const r = Math.max(1e-6, Math.hypot(s, n));
|
|
213
|
+
s /= r, n /= r;
|
|
214
|
+
let h = e.x - z(e.x, o.left, o.right), a = e.y - z(e.y, o.top, o.bottom);
|
|
215
|
+
if (Math.hypot(h, a) < 1) {
|
|
216
|
+
const l = this.nearestEdge(e, o);
|
|
217
|
+
h = l.x, a = l.y;
|
|
218
|
+
}
|
|
219
|
+
const u = Math.max(1e-6, Math.hypot(h, a));
|
|
220
|
+
h /= u, a /= u;
|
|
221
|
+
const d = 0.65;
|
|
222
|
+
let y = h * d + s * (1 - d), f = a * d + n * (1 - d);
|
|
223
|
+
const c = Math.max(1e-6, Math.hypot(y, f));
|
|
224
|
+
return { x: y / c, y: f / c };
|
|
225
|
+
}
|
|
226
|
+
simulate(t, e) {
|
|
227
|
+
const o = this.lastT > 0 ? Math.min(0.03333333333333333, Math.max(0.004166666666666667, t - this.lastT)) : 0.016666666666666666;
|
|
228
|
+
this.lastT = t;
|
|
229
|
+
for (const n of this.parts.values()) {
|
|
230
|
+
if (k(n.home) <= 0) continue;
|
|
231
|
+
let r = -this.stiffness * n.ox - this.damping * n.vx, h = -this.stiffness * n.oy - this.damping * n.vy, a = 0;
|
|
232
|
+
const u = this.magnet;
|
|
233
|
+
if (u && e && this.gate > 0.01) {
|
|
234
|
+
const y = this.pullPoint(u, e), f = n.center, c = k(n.home), l = R(n.home), g = N(n.home, n.ox, n.oy), S = z(y.x, g.left, g.right), M = z(y.y, g.top, g.bottom), m = Math.hypot(y.x - S, y.y - M) + this.softening, x = Math.min(this.maxForce, this.force * (this.reach / m) ** 3) * this.gate * this.proximity(u, e), v = this.direction(f, y, e);
|
|
235
|
+
r += x * v.x, h += x * v.y;
|
|
236
|
+
const E = Math.cos(n.angle), p = Math.sin(n.angle), b = (y.x - f.x) * E + (y.y - f.y) * p, w = -(y.x - f.x) * p + (y.y - f.y) * E, L = z(b, -c / 2, c / 2), F = z(w, -l / 2, l / 2), T = L * E - F * p, C = L * p + F * E;
|
|
237
|
+
a = this.tiltGain * (T * x * v.y - C * x * v.x) / ((c * c + l * l) / 12), n.atEdge && (a *= 0.4);
|
|
238
|
+
}
|
|
239
|
+
n.vx += r * o, n.vy += h * o;
|
|
240
|
+
const d = Math.hypot(n.vx, n.vy);
|
|
241
|
+
d > 2600 && (n.vx = n.vx / d * 2600, n.vy = n.vy / d * 2600), n.ox += n.vx * o, n.oy += n.vy * o, n.spin += (a - this.spinStiffness * n.angle - this.spinDamping * n.spin) * o, n.angle += n.spin * o, Math.abs(n.angle) > this.maxTilt && (n.angle = z(n.angle, -this.maxTilt, this.maxTilt), n.spin *= 0.4);
|
|
242
|
+
}
|
|
243
|
+
if (!e) return;
|
|
244
|
+
const s = [...this.parts.values()].filter((n) => k(n.home) > 0);
|
|
245
|
+
for (let n = 0; n < 4; n++) {
|
|
246
|
+
for (let r = 0; r < s.length; r++) for (let h = r + 1; h < s.length; h++) this.collide(s[r], s[h]);
|
|
247
|
+
for (const r of s) this.contain(r, e);
|
|
248
|
+
}
|
|
249
|
+
for (const n of s) n.angle = z(n.angle, -this.maxTilt, this.maxTilt);
|
|
250
|
+
}
|
|
251
|
+
/** Two boxes (separating axes): push apart by mass; a real knock bounces and spins them. */
|
|
252
|
+
collide(t, e) {
|
|
253
|
+
const o = t.center, s = e.center, n = k(t.home) / 2, r = R(t.home) / 2, h = k(e.home) / 2, a = R(e.home) / 2;
|
|
254
|
+
if (Math.abs(s.x - o.x) > n + h + 20 || Math.abs(s.y - o.y) > r + a + 20) return;
|
|
255
|
+
const u = [{ x: Math.cos(t.angle), y: Math.sin(t.angle) }, { x: -Math.sin(t.angle), y: Math.cos(t.angle) }], d = [{ x: Math.cos(e.angle), y: Math.sin(e.angle) }, { x: -Math.sin(e.angle), y: Math.cos(e.angle) }], y = s.x - o.x, f = s.y - o.y;
|
|
256
|
+
let c = 1 / 0, l = 0, g = 0;
|
|
257
|
+
for (const p of [...u, ...d]) {
|
|
258
|
+
const b = n * Math.abs(u[0].x * p.x + u[0].y * p.y) + r * Math.abs(u[1].x * p.x + u[1].y * p.y), w = h * Math.abs(d[0].x * p.x + d[0].y * p.y) + a * Math.abs(d[1].x * p.x + d[1].y * p.y), L = y * p.x + f * p.y, F = b + w - Math.abs(L);
|
|
259
|
+
if (F <= 0) return;
|
|
260
|
+
F < c && (c = F, l = L < 0 ? -p.x : p.x, g = L < 0 ? -p.y : p.y);
|
|
261
|
+
}
|
|
262
|
+
const S = t.home, M = e.home;
|
|
263
|
+
if (S.left < M.right - 1 && M.left < S.right - 1 && S.top < M.bottom - 1 && M.top < S.bottom - 1) return;
|
|
264
|
+
const m = 1 / t.mass, x = 1 / e.mass, v = c / (m + x);
|
|
265
|
+
t.ox -= l * v * m, t.oy -= g * v * m, e.ox += l * v * x, e.oy += g * v * x;
|
|
266
|
+
const E = (e.vx - t.vx) * l + (e.vy - t.vy) * g;
|
|
267
|
+
if (E < 0) {
|
|
268
|
+
const p = -E > this.restingSpeed, b = -(1 + (p ? this.restitution : 0)) * E / (m + x);
|
|
269
|
+
if (t.vx -= l * b * m, t.vy -= g * b * m, e.vx += l * b * x, e.vy += g * b * x, !p) return;
|
|
270
|
+
const w = (o.x + s.x) / 2, L = (o.y + s.y) / 2;
|
|
271
|
+
t.spin -= 0.5 * ((w - o.x) * g - (L - o.y) * l) * b / t.inertia, e.spin += 0.5 * ((w - s.x) * g - (L - s.y) * l) * b / e.inertia;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Keep a body inside the screen's rounded rectangle. A body whose home is
|
|
275
|
+
* already cut by the screen's edge may rest there, but go no further out. */
|
|
276
|
+
contain(t, e) {
|
|
277
|
+
const o = {
|
|
278
|
+
left: Math.min(e.left, t.home.left),
|
|
279
|
+
top: Math.min(e.top, t.home.top),
|
|
280
|
+
right: Math.max(e.right, t.home.right),
|
|
281
|
+
bottom: Math.max(e.bottom, t.home.bottom)
|
|
282
|
+
}, s = t.center, n = k(t.home) / 2, r = R(t.home) / 2, h = Math.min(t.corner, Math.min(n, r)), a = U(o, h), u = Math.max(0, this.screenCorner - h), d = Math.cos(t.angle), y = Math.sin(t.angle);
|
|
283
|
+
let f = 0, c = 0, l = 0, g = s.x, S = s.y;
|
|
284
|
+
for (const [E, p] of [[-1, -1], [1, -1], [1, 1], [-1, 1]]) {
|
|
285
|
+
const b = E * (n - h), w = p * (r - h), L = s.x + b * d - w * y, F = s.y + b * y + w * d, T = J(L, F, a, u), C = Math.hypot(T.x, T.y);
|
|
286
|
+
C > l && (l = C, f = T.x, c = T.y, g = L, S = F);
|
|
287
|
+
}
|
|
288
|
+
if (l < 0.5) {
|
|
289
|
+
t.atEdge = !1;
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const M = t.atEdge;
|
|
293
|
+
t.atEdge = !0, t.ox += f, t.oy += c;
|
|
294
|
+
const m = f / l, x = c / l, v = t.vx * m + t.vy * x;
|
|
295
|
+
if (v < 0) {
|
|
296
|
+
const E = M && -v < 900, p = E ? 0 : -v, b = E ? 0 : this.wallRestitution;
|
|
297
|
+
t.vx -= (1 + b) * v * m, t.vy -= (1 + b) * v * x;
|
|
298
|
+
const w = t.vx * m + t.vy * x, L = t.vx - w * m, F = t.vy - w * x, T = Math.hypot(L, F);
|
|
299
|
+
if (T > 1e-6) {
|
|
300
|
+
const C = E ? Math.min(T, this.wallFriction * -v) / T : 0.15;
|
|
301
|
+
t.vx -= L * C, t.vy -= F * C;
|
|
302
|
+
}
|
|
303
|
+
E ? t.spin *= 0.7 : t.spin += 0.5 * ((g - s.x) * x - (S - s.y) * m) * p * t.mass / t.inertia;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function J(i, t, e, o) {
|
|
308
|
+
const s = i < e.left + o ? e.left + o : i > e.right - o ? e.right - o : null, n = t < e.top + o ? e.top + o : t > e.bottom - o ? e.bottom - o : null;
|
|
309
|
+
if (s != null && n != null && o > 0) {
|
|
310
|
+
const a = Math.hypot(i - s, t - n);
|
|
311
|
+
return a <= o ? { x: 0, y: 0 } : { x: (s - i) * (a - o) / a, y: (n - t) * (a - o) / a };
|
|
312
|
+
}
|
|
313
|
+
let r = 0, h = 0;
|
|
314
|
+
return i < e.left ? r = e.left - i : i > e.right && (r = e.right - i), t < e.top ? h = e.top - t : t > e.bottom && (h = e.bottom - t), { x: r, y: h };
|
|
315
|
+
}
|
|
316
|
+
const K = [[0.5, 0.45], [0.5, 0.12], [0.5, 0.88], [0.15, 0.5], [0.85, 0.5]];
|
|
317
|
+
function Q(i, t = {}) {
|
|
318
|
+
const e = t.spots ?? K, o = [];
|
|
319
|
+
let s = 0, n = [], r = null, h = 0;
|
|
320
|
+
const a = performance.now() + 1500;
|
|
321
|
+
let u = !1, d;
|
|
322
|
+
const y = new Promise((m) => d = m), f = (m) => {
|
|
323
|
+
const x = i.viewport();
|
|
324
|
+
return { x: e[m][0] * x.x, y: e[m][1] * x.y };
|
|
325
|
+
}, c = (m, x = 0, v = null) => ({
|
|
326
|
+
phase: m,
|
|
327
|
+
index: s,
|
|
328
|
+
total: e.length,
|
|
329
|
+
spot: m === "spot" ? f(s) : null,
|
|
330
|
+
progress: x,
|
|
331
|
+
field: r && performance.now() - h < 2e3 ? r.strength : null,
|
|
332
|
+
zeroing: !!(r != null && r.calibrating),
|
|
333
|
+
fit: v
|
|
334
|
+
}), l = (m) => {
|
|
335
|
+
var x;
|
|
336
|
+
return (x = t.onUpdate) == null ? void 0 : x.call(t, m);
|
|
337
|
+
}, g = (m, x) => {
|
|
338
|
+
u || (u = !0, M(), clearInterval(S), l(c(m, 1, x)), d(x));
|
|
339
|
+
};
|
|
340
|
+
i.zero(), l(c("zeroing"));
|
|
341
|
+
const S = setInterval(() => {
|
|
342
|
+
!u && performance.now() < a && l(c("zeroing"));
|
|
343
|
+
}, 300), M = i.addFrameListener((m) => {
|
|
344
|
+
if (r = m, h = performance.now(), performance.now() < a || m.calibrating) {
|
|
345
|
+
l(c("zeroing"));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (m.strength < 15) {
|
|
349
|
+
n = [], l(c("spot", 0));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
n.push(m), n = n.filter((w) => w.t > m.t - 1200);
|
|
353
|
+
const x = (w) => {
|
|
354
|
+
const L = n.map((F) => F.delta[w]).sort((F, T) => F - T);
|
|
355
|
+
return L[L.length >> 1];
|
|
356
|
+
}, v = [x(0), x(1), x(2)];
|
|
357
|
+
if (Math.max(...n.map((w) => Math.hypot(w.delta[0] - v[0], w.delta[1] - v[1], w.delta[2] - v[2]))) > Math.max(4, 0.12 * m.strength)) {
|
|
358
|
+
n = [m], l(c("spot", 0));
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const p = Math.min(1, (m.t - n[0].t) / 1e3);
|
|
362
|
+
if (l(c("spot", p)), p < 1) return;
|
|
363
|
+
if (o.push({ screen: i.toScreen(f(s)), delta: v }), s++, n = [], s < e.length) {
|
|
364
|
+
l(c("spot", 0));
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const b = H(o, i.screenSize(), i.pointsPerCm);
|
|
368
|
+
if (!b) return g("failed", null);
|
|
369
|
+
i.setMagnet(b), g("done", b);
|
|
370
|
+
});
|
|
371
|
+
return { promise: y, cancel: () => g("cancelled", null) };
|
|
372
|
+
}
|
|
373
|
+
function V(i, t = {}) {
|
|
374
|
+
return Q(
|
|
375
|
+
{
|
|
376
|
+
zero: () => i.zero(),
|
|
377
|
+
addFrameListener: (e) => i.addFrameListener(e),
|
|
378
|
+
setMagnet: (e) => i.setMagnet(e),
|
|
379
|
+
viewport: () => ({ x: innerWidth, y: innerHeight }),
|
|
380
|
+
toScreen: (e) => i.toScreen(e),
|
|
381
|
+
screenSize: () => {
|
|
382
|
+
const e = i.hello;
|
|
383
|
+
return e ? { x: e.screenDp[0], y: e.screenDp[1] } : { x: screen.width, y: screen.height };
|
|
384
|
+
},
|
|
385
|
+
// Android dp: 160 per inch.
|
|
386
|
+
pointsPerCm: 160 / 2.54
|
|
387
|
+
},
|
|
388
|
+
t
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
let Z = null;
|
|
392
|
+
function A() {
|
|
393
|
+
return Z ?? (Z = new B());
|
|
394
|
+
}
|
|
395
|
+
function et() {
|
|
396
|
+
var e;
|
|
397
|
+
const i = A(), t = Y(
|
|
398
|
+
(o) => i.addStateListener(o),
|
|
399
|
+
() => {
|
|
400
|
+
var o, s;
|
|
401
|
+
return `${i.state}|${i.device}|${((o = i.hello) == null ? void 0 : o.grabStage) ?? ""}|${((s = i.hello) == null ? void 0 : s.face) ?? ""}`;
|
|
402
|
+
},
|
|
403
|
+
() => "idle||"
|
|
404
|
+
);
|
|
405
|
+
return {
|
|
406
|
+
bridge: i,
|
|
407
|
+
state: i.state,
|
|
408
|
+
device: i.device,
|
|
409
|
+
calibrated: ((e = i.hello) == null ? void 0 : e.grabStage) === 2,
|
|
410
|
+
connect: () => i.connect(),
|
|
411
|
+
key: t
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
function nt(i) {
|
|
415
|
+
const t = G(i);
|
|
416
|
+
t.current = i, P(() => A().addFrameListener((e) => t.current(e)), []);
|
|
417
|
+
}
|
|
418
|
+
function st() {
|
|
419
|
+
const [i, t] = O(A().frame);
|
|
420
|
+
return P(() => {
|
|
421
|
+
let e = 0, o = null;
|
|
422
|
+
const s = A().addFrameListener((n) => {
|
|
423
|
+
o = n, e || (e = requestAnimationFrame(() => {
|
|
424
|
+
e = 0, t(o);
|
|
425
|
+
}));
|
|
426
|
+
});
|
|
427
|
+
return () => {
|
|
428
|
+
s(), cancelAnimationFrame(e);
|
|
429
|
+
};
|
|
430
|
+
}, []), i;
|
|
431
|
+
}
|
|
432
|
+
function it(i) {
|
|
433
|
+
const t = G(i);
|
|
434
|
+
t.current = i, P(() => A().addGestureListener((e) => t.current(e)), []);
|
|
435
|
+
}
|
|
436
|
+
function ot(i, t = {}) {
|
|
437
|
+
const { selector: e = "[data-magnetic]", enabled: o = !0 } = t;
|
|
438
|
+
P(() => {
|
|
439
|
+
const s = i.current;
|
|
440
|
+
if (!s || !o || matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
441
|
+
const n = A(), r = new _();
|
|
442
|
+
r.screenCorner = 24, t.stiffness != null && (r.stiffness = t.stiffness), t.damping != null && (r.damping = t.damping), t.force != null && (r.force = t.force), t.reach != null && (r.reach = t.reach);
|
|
443
|
+
let h = [];
|
|
444
|
+
const a = () => {
|
|
445
|
+
h.forEach((f) => f.el.style.transform = ""), h = [...s.querySelectorAll(e)].filter((f) => f.getBoundingClientRect().width > 0).map((f, c) => {
|
|
446
|
+
getComputedStyle(f).display === "inline" && (f.style.display = "inline-block");
|
|
447
|
+
const l = f.getBoundingClientRect();
|
|
448
|
+
return {
|
|
449
|
+
id: String(c),
|
|
450
|
+
el: f,
|
|
451
|
+
box: { left: l.left + scrollX, top: l.top + scrollY, right: l.right + scrollX, bottom: l.bottom + scrollY },
|
|
452
|
+
corner: parseFloat(getComputedStyle(f).borderTopLeftRadius) || 6
|
|
453
|
+
};
|
|
454
|
+
}), r.parts.clear();
|
|
455
|
+
};
|
|
456
|
+
a();
|
|
457
|
+
const u = () => a();
|
|
458
|
+
addEventListener("resize", u);
|
|
459
|
+
let d = 0;
|
|
460
|
+
const y = () => {
|
|
461
|
+
var S;
|
|
462
|
+
const f = performance.now() / 1e3, c = n.offset();
|
|
463
|
+
for (const M of h)
|
|
464
|
+
r.setHome(M.id, {
|
|
465
|
+
left: M.box.left - scrollX + c.x,
|
|
466
|
+
top: M.box.top - scrollY + c.y,
|
|
467
|
+
right: M.box.right - scrollX + c.x,
|
|
468
|
+
bottom: M.box.bottom - scrollY + c.y
|
|
469
|
+
}, M.corner);
|
|
470
|
+
const l = n.frame, g = { left: c.x, top: c.y, right: c.x + innerWidth, bottom: c.y + innerHeight };
|
|
471
|
+
l ? r.step(l, f, g, ((S = n.hello) == null ? void 0 : S.face) ?? 1) : r.relax(f);
|
|
472
|
+
for (const M of h) {
|
|
473
|
+
const m = r.parts.get(M.id);
|
|
474
|
+
m && (M.el.style.transform = m.resting ? "" : `translate(${m.ox.toFixed(2)}px, ${m.oy.toFixed(2)}px) rotate(${m.angle.toFixed(4)}rad)`);
|
|
475
|
+
}
|
|
476
|
+
d = requestAnimationFrame(y);
|
|
477
|
+
};
|
|
478
|
+
return d = requestAnimationFrame(y), () => {
|
|
479
|
+
cancelAnimationFrame(d), removeEventListener("resize", u), h.forEach((f) => f.el.style.transform = "");
|
|
480
|
+
};
|
|
481
|
+
}, [i, e, o, t.stiffness, t.damping, t.force, t.reach]);
|
|
482
|
+
}
|
|
483
|
+
function rt({ open: i, onClose: t }) {
|
|
484
|
+
const [e, o] = O(null), s = G(t);
|
|
485
|
+
s.current = t;
|
|
486
|
+
const n = G(() => {
|
|
487
|
+
});
|
|
488
|
+
if (P(() => {
|
|
489
|
+
if (!i) return;
|
|
490
|
+
const u = V(A(), { onUpdate: o });
|
|
491
|
+
return n.current = u.cancel, u.promise.then((d) => {
|
|
492
|
+
var y;
|
|
493
|
+
return (y = s.current) == null ? void 0 : y.call(s, !!d);
|
|
494
|
+
}), () => u.cancel();
|
|
495
|
+
}, [i]), !i || !e || e.phase === "done" || e.phase === "cancelled") return null;
|
|
496
|
+
const r = e.phase === "zeroing" ? "Keep the magnet away for a moment…" : e.phase === "failed" ? "That didn't fit. Try again, holding the magnet still over each ring." : `Ring ${e.index + 1} of ${e.total}: hold the magnet 1–2 cm above it and keep it still.`, h = e.field == null ? "No readings from the bridge. Open Magnet Bridge and tap Start bridge." : `${e.field.toFixed(0)} µT${e.zeroing ? " · zeroing" : ""}`, a = e.spot;
|
|
497
|
+
return /* @__PURE__ */ X("div", { style: { position: "fixed", inset: 0, zIndex: 2147483e3, background: "rgba(0,0,0,0.94)", color: "#fff", font: "500 16px/24px system-ui, sans-serif", touchAction: "none" }, children: [
|
|
498
|
+
a && /* @__PURE__ */ $("div", { style: { position: "absolute", left: a.x, top: a.y, width: 72, height: 72, margin: -36, borderRadius: "50%", boxShadow: "inset 0 0 0 3px #fff" }, children: /* @__PURE__ */ $("i", { style: { position: "absolute", inset: 10, borderRadius: "50%", background: "#4f9cff", transform: `scale(${e.progress.toFixed(3)})` } }) }),
|
|
499
|
+
/* @__PURE__ */ $("p", { style: { position: "absolute", left: 16, right: 16, top: a ? a.y < innerHeight / 2 ? a.y + 64 : a.y - 120 : "40%", margin: 0, textAlign: "center" }, children: r }),
|
|
500
|
+
/* @__PURE__ */ $("p", { style: { position: "absolute", left: 16, right: 16, bottom: 88, margin: 0, textAlign: "center", font: "400 13px/18px system-ui, sans-serif", color: "#9a9a9a" }, children: h }),
|
|
501
|
+
/* @__PURE__ */ $("button", { type: "button", onClick: () => n.current(), style: { position: "absolute", left: "50%", bottom: 32, transform: "translateX(-50%)", height: 40, padding: "0 18px", border: 0, borderRadius: 999, background: "#2a2a2a", color: "#fff", font: "500 14px/1 system-ui, sans-serif" }, children: "Cancel" })
|
|
502
|
+
] });
|
|
503
|
+
}
|
|
504
|
+
function at(i = A()) {
|
|
505
|
+
let t = !1, e = { x: 0, y: 0 };
|
|
506
|
+
const o = i.offset.bind(i);
|
|
507
|
+
i.offset = () => ({ x: 0, y: 0 });
|
|
508
|
+
const s = (a) => {
|
|
509
|
+
t = !0, e = { x: a.clientX, y: a.clientY };
|
|
510
|
+
}, n = (a) => {
|
|
511
|
+
e = { x: a.clientX, y: a.clientY };
|
|
512
|
+
}, r = () => {
|
|
513
|
+
t = !1;
|
|
514
|
+
};
|
|
515
|
+
addEventListener("pointerdown", s), addEventListener("pointermove", n), addEventListener("pointerup", r);
|
|
516
|
+
const h = window.setInterval(() => {
|
|
517
|
+
i.frame = {
|
|
518
|
+
t: performance.now(),
|
|
519
|
+
pos: t ? e : null,
|
|
520
|
+
spread: 20,
|
|
521
|
+
height: null,
|
|
522
|
+
presence: 1,
|
|
523
|
+
pull: t ? 0.8 : 0,
|
|
524
|
+
strength: t ? 80 : 0,
|
|
525
|
+
delta: t ? [0, 0, -40] : [0, 0, 0],
|
|
526
|
+
tooClose: !1,
|
|
527
|
+
calibrating: !1
|
|
528
|
+
};
|
|
529
|
+
}, 20);
|
|
530
|
+
return () => {
|
|
531
|
+
clearInterval(h), removeEventListener("pointerdown", s), removeEventListener("pointermove", n), removeEventListener("pointerup", r), i.offset = o;
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
export {
|
|
535
|
+
K as DEFAULT_SPOTS,
|
|
536
|
+
B as MagnetBridge,
|
|
537
|
+
rt as MagnetCalibration,
|
|
538
|
+
_ as MagneticField,
|
|
539
|
+
W as Particle,
|
|
540
|
+
V as calibrate,
|
|
541
|
+
H as fitCalibration,
|
|
542
|
+
A as getMagnetBridge,
|
|
543
|
+
Q as runCalibration,
|
|
544
|
+
at as simulateMagnetWithPointer,
|
|
545
|
+
st as useMagnet,
|
|
546
|
+
et as useMagnetBridge,
|
|
547
|
+
nt as useMagnetFrame,
|
|
548
|
+
it as useMagnetGestures,
|
|
549
|
+
ot as useMagneticPull
|
|
550
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "magnetkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Magnetic UI for React on the web: a real magnet near an Android phone pulls your interface, through the Magnet Bridge app that runs the magnet model on the phone.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.cjs.js",
|
|
7
|
+
"module": "dist/index.es.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.es.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.cjs.js"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Jakubantalik/Libraries.dev.git",
|
|
30
|
+
"directory": "packages/magnetkit-web"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://libraries.dev/magnetic",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"provenance": true
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"dev": "vite",
|
|
39
|
+
"build": "vite build",
|
|
40
|
+
"typecheck": "tsc --noEmit"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/react": "^18.2.0",
|
|
47
|
+
"@vitejs/plugin-react": "^4.2.0",
|
|
48
|
+
"react": "^18.2.0",
|
|
49
|
+
"typescript": "^5.3.0",
|
|
50
|
+
"vite": "^5.0.0",
|
|
51
|
+
"vite-plugin-dts": "^3.7.0",
|
|
52
|
+
"react-dom": "^18.2.0",
|
|
53
|
+
"@types/react-dom": "^18.2.0"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"react",
|
|
57
|
+
"magnet",
|
|
58
|
+
"magnetometer",
|
|
59
|
+
"magnetic ui",
|
|
60
|
+
"android",
|
|
61
|
+
"physics",
|
|
62
|
+
"gesture"
|
|
63
|
+
],
|
|
64
|
+
"author": "Jakub Antalik",
|
|
65
|
+
"license": "MIT"
|
|
66
|
+
}
|