@hapticjs/core 0.3.0 → 0.4.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/dist/index.cjs +741 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +296 -1
- package/dist/index.d.ts +296 -1
- package/dist/index.js +729 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2620,9 +2620,737 @@ var physics = {
|
|
|
2620
2620
|
pendulum
|
|
2621
2621
|
};
|
|
2622
2622
|
|
|
2623
|
+
// src/middleware/middleware.ts
|
|
2624
|
+
var MiddlewareManager = class {
|
|
2625
|
+
constructor() {
|
|
2626
|
+
this.middleware = [];
|
|
2627
|
+
}
|
|
2628
|
+
/** Register a middleware */
|
|
2629
|
+
use(middleware) {
|
|
2630
|
+
this.middleware.push(middleware);
|
|
2631
|
+
}
|
|
2632
|
+
/** Remove a middleware by name */
|
|
2633
|
+
remove(name) {
|
|
2634
|
+
this.middleware = this.middleware.filter((m) => m.name !== name);
|
|
2635
|
+
}
|
|
2636
|
+
/** Run all middleware in order */
|
|
2637
|
+
process(steps) {
|
|
2638
|
+
let result = steps;
|
|
2639
|
+
for (const m of this.middleware) {
|
|
2640
|
+
result = m.process(result);
|
|
2641
|
+
}
|
|
2642
|
+
return result;
|
|
2643
|
+
}
|
|
2644
|
+
/** Remove all middleware */
|
|
2645
|
+
clear() {
|
|
2646
|
+
this.middleware = [];
|
|
2647
|
+
}
|
|
2648
|
+
/** List registered middleware names */
|
|
2649
|
+
list() {
|
|
2650
|
+
return this.middleware.map((m) => m.name);
|
|
2651
|
+
}
|
|
2652
|
+
};
|
|
2653
|
+
function intensityScaler(scale) {
|
|
2654
|
+
return {
|
|
2655
|
+
name: "intensityScaler",
|
|
2656
|
+
process: (steps) => steps.map((s) => ({
|
|
2657
|
+
...s,
|
|
2658
|
+
intensity: Math.min(1, Math.max(0, s.intensity * scale))
|
|
2659
|
+
}))
|
|
2660
|
+
};
|
|
2661
|
+
}
|
|
2662
|
+
function durationScaler(scale) {
|
|
2663
|
+
return {
|
|
2664
|
+
name: "durationScaler",
|
|
2665
|
+
process: (steps) => steps.map((s) => ({
|
|
2666
|
+
...s,
|
|
2667
|
+
duration: Math.max(20, Math.round(s.duration * scale))
|
|
2668
|
+
}))
|
|
2669
|
+
};
|
|
2670
|
+
}
|
|
2671
|
+
function intensityClamper(min, max) {
|
|
2672
|
+
return {
|
|
2673
|
+
name: "intensityClamper",
|
|
2674
|
+
process: (steps) => steps.map((s) => ({
|
|
2675
|
+
...s,
|
|
2676
|
+
intensity: Math.min(max, Math.max(min, s.intensity))
|
|
2677
|
+
}))
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
function patternRepeater(times) {
|
|
2681
|
+
return {
|
|
2682
|
+
name: "patternRepeater",
|
|
2683
|
+
process: (steps) => {
|
|
2684
|
+
const result = [];
|
|
2685
|
+
for (let i = 0; i < times; i++) {
|
|
2686
|
+
result.push(...steps.map((s) => ({ ...s })));
|
|
2687
|
+
}
|
|
2688
|
+
return result;
|
|
2689
|
+
}
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
function reverser() {
|
|
2693
|
+
return {
|
|
2694
|
+
name: "reverser",
|
|
2695
|
+
process: (steps) => [...steps].reverse()
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
function accessibilityBooster() {
|
|
2699
|
+
return {
|
|
2700
|
+
name: "accessibilityBooster",
|
|
2701
|
+
process: (steps) => steps.map((s) => ({
|
|
2702
|
+
...s,
|
|
2703
|
+
intensity: Math.min(1, s.intensity * 1.3),
|
|
2704
|
+
duration: Math.max(20, Math.round(s.duration * 1.2))
|
|
2705
|
+
}))
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
// src/profiles/intensity-profiles.ts
|
|
2710
|
+
var profiles = {
|
|
2711
|
+
off: {
|
|
2712
|
+
name: "off",
|
|
2713
|
+
hapticScale: 0,
|
|
2714
|
+
durationScale: 0,
|
|
2715
|
+
soundEnabled: false,
|
|
2716
|
+
soundVolume: 0,
|
|
2717
|
+
visualEnabled: false
|
|
2718
|
+
},
|
|
2719
|
+
subtle: {
|
|
2720
|
+
name: "subtle",
|
|
2721
|
+
hapticScale: 0.5,
|
|
2722
|
+
durationScale: 0.7,
|
|
2723
|
+
soundEnabled: true,
|
|
2724
|
+
soundVolume: 0.1,
|
|
2725
|
+
visualEnabled: true
|
|
2726
|
+
},
|
|
2727
|
+
normal: {
|
|
2728
|
+
name: "normal",
|
|
2729
|
+
hapticScale: 1,
|
|
2730
|
+
durationScale: 1,
|
|
2731
|
+
soundEnabled: true,
|
|
2732
|
+
soundVolume: 0.3,
|
|
2733
|
+
visualEnabled: true
|
|
2734
|
+
},
|
|
2735
|
+
strong: {
|
|
2736
|
+
name: "strong",
|
|
2737
|
+
hapticScale: 1.3,
|
|
2738
|
+
durationScale: 1.2,
|
|
2739
|
+
soundEnabled: true,
|
|
2740
|
+
soundVolume: 0.5,
|
|
2741
|
+
visualEnabled: true
|
|
2742
|
+
},
|
|
2743
|
+
intense: {
|
|
2744
|
+
name: "intense",
|
|
2745
|
+
hapticScale: 1.8,
|
|
2746
|
+
durationScale: 1.5,
|
|
2747
|
+
soundEnabled: true,
|
|
2748
|
+
soundVolume: 0.7,
|
|
2749
|
+
visualEnabled: true
|
|
2750
|
+
},
|
|
2751
|
+
accessible: {
|
|
2752
|
+
name: "accessible",
|
|
2753
|
+
hapticScale: 1.5,
|
|
2754
|
+
durationScale: 1.3,
|
|
2755
|
+
soundEnabled: true,
|
|
2756
|
+
soundVolume: 0.6,
|
|
2757
|
+
visualEnabled: true
|
|
2758
|
+
}
|
|
2759
|
+
};
|
|
2760
|
+
var ProfileManager = class {
|
|
2761
|
+
constructor() {
|
|
2762
|
+
this.registry = /* @__PURE__ */ new Map();
|
|
2763
|
+
for (const [name, profile] of Object.entries(profiles)) {
|
|
2764
|
+
this.registry.set(name, profile);
|
|
2765
|
+
}
|
|
2766
|
+
this.currentProfile = profiles.normal;
|
|
2767
|
+
}
|
|
2768
|
+
/** Apply a profile by name or custom profile object */
|
|
2769
|
+
setProfile(name) {
|
|
2770
|
+
if (typeof name === "string") {
|
|
2771
|
+
const profile = this.registry.get(name);
|
|
2772
|
+
if (!profile) {
|
|
2773
|
+
throw new Error(`Unknown profile: "${name}"`);
|
|
2774
|
+
}
|
|
2775
|
+
this.currentProfile = profile;
|
|
2776
|
+
} else {
|
|
2777
|
+
this.registry.set(name.name, name);
|
|
2778
|
+
this.currentProfile = name;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
/** Get the current profile */
|
|
2782
|
+
getProfile() {
|
|
2783
|
+
return this.currentProfile;
|
|
2784
|
+
}
|
|
2785
|
+
/** List available profile names */
|
|
2786
|
+
listProfiles() {
|
|
2787
|
+
return Array.from(this.registry.keys());
|
|
2788
|
+
}
|
|
2789
|
+
/** Register a custom profile */
|
|
2790
|
+
registerProfile(profile) {
|
|
2791
|
+
this.registry.set(profile.name, profile);
|
|
2792
|
+
}
|
|
2793
|
+
/** Convert current profile to a HapticMiddleware (intensity + duration scaling) */
|
|
2794
|
+
toMiddleware() {
|
|
2795
|
+
const profile = this.currentProfile;
|
|
2796
|
+
const iScaler = intensityScaler(profile.hapticScale);
|
|
2797
|
+
const dScaler = durationScaler(profile.durationScale);
|
|
2798
|
+
return {
|
|
2799
|
+
name: `profile:${profile.name}`,
|
|
2800
|
+
process: (steps) => dScaler.process(iScaler.process(steps))
|
|
2801
|
+
};
|
|
2802
|
+
}
|
|
2803
|
+
/** Current profile name */
|
|
2804
|
+
get current() {
|
|
2805
|
+
return this.currentProfile.name;
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
|
|
2809
|
+
// src/experiment/ab-testing.ts
|
|
2810
|
+
var HapticExperiment = class {
|
|
2811
|
+
constructor(name, variants) {
|
|
2812
|
+
this.assignments = /* @__PURE__ */ new Map();
|
|
2813
|
+
this.tracking = [];
|
|
2814
|
+
this._name = name;
|
|
2815
|
+
this.variants = variants;
|
|
2816
|
+
this.variantNames = Object.keys(variants);
|
|
2817
|
+
if (this.variantNames.length === 0) {
|
|
2818
|
+
throw new Error("Experiment must have at least one variant");
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
/** Experiment name */
|
|
2822
|
+
get name() {
|
|
2823
|
+
return this._name;
|
|
2824
|
+
}
|
|
2825
|
+
/**
|
|
2826
|
+
* Randomly assign a variant (consistent for same userId via simple hash).
|
|
2827
|
+
* If no userId is provided, generates a random assignment.
|
|
2828
|
+
*/
|
|
2829
|
+
assign(userId) {
|
|
2830
|
+
const id = userId ?? `anon-${Math.random().toString(36).slice(2)}`;
|
|
2831
|
+
const existing = this.assignments.get(id);
|
|
2832
|
+
if (existing) return existing;
|
|
2833
|
+
const hash = this._hash(id);
|
|
2834
|
+
const index = hash % this.variantNames.length;
|
|
2835
|
+
const variant = this.variantNames[index];
|
|
2836
|
+
this.assignments.set(id, variant);
|
|
2837
|
+
return variant;
|
|
2838
|
+
}
|
|
2839
|
+
/** Get the assigned variant pattern for a user */
|
|
2840
|
+
getVariant(userId) {
|
|
2841
|
+
const id = userId ?? void 0;
|
|
2842
|
+
if (!id) return void 0;
|
|
2843
|
+
const variant = this.assignments.get(id);
|
|
2844
|
+
if (!variant) return void 0;
|
|
2845
|
+
return this.variants[variant];
|
|
2846
|
+
}
|
|
2847
|
+
/** Track an event for a user */
|
|
2848
|
+
track(userId, event, value) {
|
|
2849
|
+
const variant = this.assignments.get(userId);
|
|
2850
|
+
if (!variant) return;
|
|
2851
|
+
this.tracking.push({
|
|
2852
|
+
userId,
|
|
2853
|
+
variant,
|
|
2854
|
+
event,
|
|
2855
|
+
value,
|
|
2856
|
+
timestamp: Date.now()
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
/** Get aggregated results per variant */
|
|
2860
|
+
getResults() {
|
|
2861
|
+
const results = {};
|
|
2862
|
+
for (const name of this.variantNames) {
|
|
2863
|
+
results[name] = { assignments: 0, events: {} };
|
|
2864
|
+
}
|
|
2865
|
+
for (const variant of this.assignments.values()) {
|
|
2866
|
+
if (results[variant]) {
|
|
2867
|
+
results[variant].assignments++;
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
for (const entry of this.tracking) {
|
|
2871
|
+
if (results[entry.variant]) {
|
|
2872
|
+
const events = results[entry.variant].events;
|
|
2873
|
+
events[entry.event] = (events[entry.event] ?? 0) + 1;
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
return results;
|
|
2877
|
+
}
|
|
2878
|
+
/** Clear all tracking data and assignments */
|
|
2879
|
+
reset() {
|
|
2880
|
+
this.assignments.clear();
|
|
2881
|
+
this.tracking = [];
|
|
2882
|
+
}
|
|
2883
|
+
/** Simple string hash for deterministic variant assignment */
|
|
2884
|
+
_hash(str) {
|
|
2885
|
+
let hash = 0;
|
|
2886
|
+
for (let i = 0; i < str.length; i++) {
|
|
2887
|
+
const char = str.charCodeAt(i);
|
|
2888
|
+
hash = (hash << 5) - hash + char;
|
|
2889
|
+
hash = hash & hash;
|
|
2890
|
+
}
|
|
2891
|
+
return Math.abs(hash);
|
|
2892
|
+
}
|
|
2893
|
+
};
|
|
2894
|
+
|
|
2895
|
+
// src/rhythm/rhythm-sync.ts
|
|
2896
|
+
var RhythmSync = class {
|
|
2897
|
+
constructor(options = {}) {
|
|
2898
|
+
// reserved for pattern-based rhythm
|
|
2899
|
+
this._isPlaying = false;
|
|
2900
|
+
this._beatCount = 0;
|
|
2901
|
+
this._intervalId = null;
|
|
2902
|
+
this._callbacks = [];
|
|
2903
|
+
this._tapTimestamps = [];
|
|
2904
|
+
this._audioElement = null;
|
|
2905
|
+
this._syncEngine = null;
|
|
2906
|
+
this._syncEffect = "tap";
|
|
2907
|
+
this._bpm = Math.min(300, Math.max(60, options.bpm ?? 120));
|
|
2908
|
+
this._intensity = Math.min(1, Math.max(0, options.intensity ?? 0.7));
|
|
2909
|
+
this._pattern = options.pattern ?? "";
|
|
2910
|
+
}
|
|
2911
|
+
// ─── BPM Control ─────────────────────────────────────────
|
|
2912
|
+
/** Set beats per minute (clamped to 60-300) */
|
|
2913
|
+
setBPM(bpm) {
|
|
2914
|
+
this._bpm = Math.min(300, Math.max(60, bpm));
|
|
2915
|
+
if (this._isPlaying) {
|
|
2916
|
+
this._stopInterval();
|
|
2917
|
+
this._startInterval();
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
/**
|
|
2921
|
+
* Store an audio element reference for sync.
|
|
2922
|
+
* Since Web Audio API's AnalyserNode isn't reliably available in all
|
|
2923
|
+
* environments, use tapTempo() for BPM detection instead.
|
|
2924
|
+
*/
|
|
2925
|
+
detectBPM(audioElement) {
|
|
2926
|
+
this._audioElement = audioElement;
|
|
2927
|
+
}
|
|
2928
|
+
/**
|
|
2929
|
+
* Tap tempo — call repeatedly to set BPM from tap intervals.
|
|
2930
|
+
* Calculates average BPM from the last 4-8 taps.
|
|
2931
|
+
* Returns the current estimated BPM.
|
|
2932
|
+
*/
|
|
2933
|
+
tapTempo() {
|
|
2934
|
+
const now = Date.now();
|
|
2935
|
+
this._tapTimestamps.push(now);
|
|
2936
|
+
if (this._tapTimestamps.length > 8) {
|
|
2937
|
+
this._tapTimestamps = this._tapTimestamps.slice(-8);
|
|
2938
|
+
}
|
|
2939
|
+
if (this._tapTimestamps.length < 2) {
|
|
2940
|
+
return this._bpm;
|
|
2941
|
+
}
|
|
2942
|
+
const timestamps = this._tapTimestamps.slice(-8);
|
|
2943
|
+
const intervals = [];
|
|
2944
|
+
for (let i = 1; i < timestamps.length; i++) {
|
|
2945
|
+
intervals.push(timestamps[i] - timestamps[i - 1]);
|
|
2946
|
+
}
|
|
2947
|
+
const avgInterval = intervals.reduce((sum, v) => sum + v, 0) / intervals.length;
|
|
2948
|
+
if (avgInterval > 0) {
|
|
2949
|
+
const estimatedBPM = Math.round(6e4 / avgInterval);
|
|
2950
|
+
this._bpm = Math.min(300, Math.max(60, estimatedBPM));
|
|
2951
|
+
}
|
|
2952
|
+
return this._bpm;
|
|
2953
|
+
}
|
|
2954
|
+
// ─── Playback ────────────────────────────────────────────
|
|
2955
|
+
/** Start emitting beats at the current BPM */
|
|
2956
|
+
start(callback) {
|
|
2957
|
+
if (this._isPlaying) return;
|
|
2958
|
+
if (callback) {
|
|
2959
|
+
this._callbacks.push(callback);
|
|
2960
|
+
}
|
|
2961
|
+
this._isPlaying = true;
|
|
2962
|
+
this._beatCount = 0;
|
|
2963
|
+
this._startInterval();
|
|
2964
|
+
}
|
|
2965
|
+
/** Stop the rhythm */
|
|
2966
|
+
stop() {
|
|
2967
|
+
this._isPlaying = false;
|
|
2968
|
+
this._stopInterval();
|
|
2969
|
+
}
|
|
2970
|
+
/** Register a beat callback */
|
|
2971
|
+
onBeat(callback) {
|
|
2972
|
+
this._callbacks.push(callback);
|
|
2973
|
+
}
|
|
2974
|
+
// ─── Haptic Sync ────────────────────────────────────────
|
|
2975
|
+
/**
|
|
2976
|
+
* Auto-trigger a haptic effect on each beat.
|
|
2977
|
+
* Calls engine.tap() by default, or the specified semantic method.
|
|
2978
|
+
*/
|
|
2979
|
+
syncHaptic(engine, effect = "tap") {
|
|
2980
|
+
this._syncEngine = engine;
|
|
2981
|
+
this._syncEffect = effect;
|
|
2982
|
+
}
|
|
2983
|
+
// ─── Getters ─────────────────────────────────────────────
|
|
2984
|
+
/** Current BPM */
|
|
2985
|
+
get bpm() {
|
|
2986
|
+
return this._bpm;
|
|
2987
|
+
}
|
|
2988
|
+
/** Whether rhythm is active */
|
|
2989
|
+
get isPlaying() {
|
|
2990
|
+
return this._isPlaying;
|
|
2991
|
+
}
|
|
2992
|
+
/** Total beats since start */
|
|
2993
|
+
get beatCount() {
|
|
2994
|
+
return this._beatCount;
|
|
2995
|
+
}
|
|
2996
|
+
/** Current pattern string */
|
|
2997
|
+
get pattern() {
|
|
2998
|
+
return this._pattern;
|
|
2999
|
+
}
|
|
3000
|
+
/** The attached audio element, if any */
|
|
3001
|
+
get audioElement() {
|
|
3002
|
+
return this._audioElement;
|
|
3003
|
+
}
|
|
3004
|
+
// ─── Lifecycle ───────────────────────────────────────────
|
|
3005
|
+
/** Clean up intervals and callbacks */
|
|
3006
|
+
dispose() {
|
|
3007
|
+
this.stop();
|
|
3008
|
+
this._callbacks = [];
|
|
3009
|
+
this._tapTimestamps = [];
|
|
3010
|
+
this._syncEngine = null;
|
|
3011
|
+
this._audioElement = null;
|
|
3012
|
+
}
|
|
3013
|
+
// ─── Internal ────────────────────────────────────────────
|
|
3014
|
+
_startInterval() {
|
|
3015
|
+
const intervalMs = 6e4 / this._bpm;
|
|
3016
|
+
this._intervalId = setInterval(() => {
|
|
3017
|
+
this._beatCount++;
|
|
3018
|
+
const beat = this._beatCount;
|
|
3019
|
+
for (const cb of this._callbacks) {
|
|
3020
|
+
cb(beat);
|
|
3021
|
+
}
|
|
3022
|
+
if (this._syncEngine) {
|
|
3023
|
+
const engine = this._syncEngine;
|
|
3024
|
+
if (typeof engine[this._syncEffect] === "function") {
|
|
3025
|
+
engine[this._syncEffect](this._intensity);
|
|
3026
|
+
} else if (typeof engine.tap === "function") {
|
|
3027
|
+
engine.tap(this._intensity);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
}, intervalMs);
|
|
3031
|
+
}
|
|
3032
|
+
_stopInterval() {
|
|
3033
|
+
if (this._intervalId !== null) {
|
|
3034
|
+
clearInterval(this._intervalId);
|
|
3035
|
+
this._intervalId = null;
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
};
|
|
3039
|
+
|
|
3040
|
+
// src/motion/motion-detector.ts
|
|
3041
|
+
var MotionDetector = class {
|
|
3042
|
+
constructor(options = {}) {
|
|
3043
|
+
this._isListening = false;
|
|
3044
|
+
this._callbacks = {
|
|
3045
|
+
shake: [],
|
|
3046
|
+
tilt: [],
|
|
3047
|
+
rotation: [],
|
|
3048
|
+
flip: []
|
|
3049
|
+
};
|
|
3050
|
+
this._lastOrientation = null;
|
|
3051
|
+
this._lastFlipState = null;
|
|
3052
|
+
this._boundMotionHandler = null;
|
|
3053
|
+
this._boundOrientationHandler = null;
|
|
3054
|
+
this._shakeThreshold = options.shakeThreshold ?? 15;
|
|
3055
|
+
this._tiltThreshold = options.tiltThreshold ?? 10;
|
|
3056
|
+
}
|
|
3057
|
+
// ─── Detection ───────────────────────────────────────────
|
|
3058
|
+
/** Whether DeviceMotion API is available */
|
|
3059
|
+
get isSupported() {
|
|
3060
|
+
if (typeof window === "undefined") return false;
|
|
3061
|
+
return "DeviceMotionEvent" in window;
|
|
3062
|
+
}
|
|
3063
|
+
/** Whether currently listening for motion events */
|
|
3064
|
+
get isListening() {
|
|
3065
|
+
return this._isListening;
|
|
3066
|
+
}
|
|
3067
|
+
// ─── Permission ──────────────────────────────────────────
|
|
3068
|
+
/**
|
|
3069
|
+
* Request permission for motion events (required on iOS 13+).
|
|
3070
|
+
* Returns true if permission was granted.
|
|
3071
|
+
*/
|
|
3072
|
+
async requestPermission() {
|
|
3073
|
+
if (typeof window === "undefined") return false;
|
|
3074
|
+
const DeviceMotionEventRef = window.DeviceMotionEvent;
|
|
3075
|
+
if (DeviceMotionEventRef && typeof DeviceMotionEventRef.requestPermission === "function") {
|
|
3076
|
+
try {
|
|
3077
|
+
const result = await DeviceMotionEventRef.requestPermission();
|
|
3078
|
+
return result === "granted";
|
|
3079
|
+
} catch {
|
|
3080
|
+
return false;
|
|
3081
|
+
}
|
|
3082
|
+
}
|
|
3083
|
+
return this.isSupported;
|
|
3084
|
+
}
|
|
3085
|
+
// ─── Lifecycle ───────────────────────────────────────────
|
|
3086
|
+
/** Begin listening to device motion and orientation events */
|
|
3087
|
+
start() {
|
|
3088
|
+
if (this._isListening) return;
|
|
3089
|
+
if (typeof window === "undefined") return;
|
|
3090
|
+
this._boundMotionHandler = this._handleMotion.bind(this);
|
|
3091
|
+
this._boundOrientationHandler = this._handleOrientation.bind(this);
|
|
3092
|
+
window.addEventListener("devicemotion", this._boundMotionHandler);
|
|
3093
|
+
window.addEventListener("deviceorientation", this._boundOrientationHandler);
|
|
3094
|
+
this._isListening = true;
|
|
3095
|
+
}
|
|
3096
|
+
/** Stop listening to motion events */
|
|
3097
|
+
stop() {
|
|
3098
|
+
if (!this._isListening) return;
|
|
3099
|
+
if (typeof window === "undefined") return;
|
|
3100
|
+
if (this._boundMotionHandler) {
|
|
3101
|
+
window.removeEventListener("devicemotion", this._boundMotionHandler);
|
|
3102
|
+
}
|
|
3103
|
+
if (this._boundOrientationHandler) {
|
|
3104
|
+
window.removeEventListener("deviceorientation", this._boundOrientationHandler);
|
|
3105
|
+
}
|
|
3106
|
+
this._isListening = false;
|
|
3107
|
+
this._lastOrientation = null;
|
|
3108
|
+
this._lastFlipState = null;
|
|
3109
|
+
}
|
|
3110
|
+
// ─── Callbacks ───────────────────────────────────────────
|
|
3111
|
+
/** Register callback for shake events. Intensity is 0-1 based on acceleration. */
|
|
3112
|
+
onShake(callback) {
|
|
3113
|
+
this._callbacks.shake.push(callback);
|
|
3114
|
+
}
|
|
3115
|
+
/** Register callback for tilt changes. Direction x/y are -1 to 1. */
|
|
3116
|
+
onTilt(callback) {
|
|
3117
|
+
this._callbacks.tilt.push(callback);
|
|
3118
|
+
}
|
|
3119
|
+
/** Register callback for rotation. Angle in degrees. */
|
|
3120
|
+
onRotation(callback) {
|
|
3121
|
+
this._callbacks.rotation.push(callback);
|
|
3122
|
+
}
|
|
3123
|
+
/** Register callback for device flip (face-down/up toggle). */
|
|
3124
|
+
onFlip(callback) {
|
|
3125
|
+
this._callbacks.flip.push(callback);
|
|
3126
|
+
}
|
|
3127
|
+
// ─── Cleanup ─────────────────────────────────────────────
|
|
3128
|
+
/** Remove all listeners and callbacks */
|
|
3129
|
+
dispose() {
|
|
3130
|
+
this.stop();
|
|
3131
|
+
this._callbacks = { shake: [], tilt: [], rotation: [], flip: [] };
|
|
3132
|
+
}
|
|
3133
|
+
// ─── Internal ────────────────────────────────────────────
|
|
3134
|
+
_handleMotion(event) {
|
|
3135
|
+
const accel = event.accelerationIncludingGravity;
|
|
3136
|
+
if (!accel) return;
|
|
3137
|
+
const { x, y, z } = accel;
|
|
3138
|
+
if (x == null || y == null || z == null) return;
|
|
3139
|
+
const magnitude = Math.sqrt(x * x + y * y + z * z) - 9.81;
|
|
3140
|
+
if (magnitude > this._shakeThreshold) {
|
|
3141
|
+
const intensity = Math.min(
|
|
3142
|
+
1,
|
|
3143
|
+
(magnitude - this._shakeThreshold) / this._shakeThreshold
|
|
3144
|
+
);
|
|
3145
|
+
for (const cb of this._callbacks.shake) {
|
|
3146
|
+
cb(intensity);
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
_handleOrientation(event) {
|
|
3151
|
+
const { alpha, beta, gamma } = event;
|
|
3152
|
+
if (beta == null || gamma == null) return;
|
|
3153
|
+
if (this._lastOrientation) {
|
|
3154
|
+
const deltaBeta = Math.abs(beta - this._lastOrientation.beta);
|
|
3155
|
+
const deltaGamma = Math.abs(gamma - this._lastOrientation.gamma);
|
|
3156
|
+
if (deltaBeta > this._tiltThreshold || deltaGamma > this._tiltThreshold) {
|
|
3157
|
+
const tiltX = Math.max(-1, Math.min(1, gamma / 90));
|
|
3158
|
+
const tiltY = Math.max(-1, Math.min(1, beta / 180));
|
|
3159
|
+
for (const cb of this._callbacks.tilt) {
|
|
3160
|
+
cb({ x: tiltX, y: tiltY });
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
this._lastOrientation = { beta, gamma };
|
|
3165
|
+
if (alpha != null) {
|
|
3166
|
+
for (const cb of this._callbacks.rotation) {
|
|
3167
|
+
cb(alpha);
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
const isFaceDown = Math.abs(beta) > 140;
|
|
3171
|
+
if (this._lastFlipState !== null && isFaceDown !== this._lastFlipState) {
|
|
3172
|
+
for (const cb of this._callbacks.flip) {
|
|
3173
|
+
cb();
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
this._lastFlipState = isFaceDown;
|
|
3177
|
+
}
|
|
3178
|
+
};
|
|
3179
|
+
|
|
3180
|
+
// src/accessibility/haptic-a11y.ts
|
|
3181
|
+
var HapticA11y = class {
|
|
3182
|
+
constructor(engine, options = {}) {
|
|
3183
|
+
this._isAttached = false;
|
|
3184
|
+
this._root = null;
|
|
3185
|
+
this._observer = null;
|
|
3186
|
+
this._focusChangeCallback = null;
|
|
3187
|
+
this._formErrorCallback = null;
|
|
3188
|
+
// Bound handlers for cleanup
|
|
3189
|
+
this._handleFocusIn = null;
|
|
3190
|
+
this._handleFocusOut = null;
|
|
3191
|
+
this._handleInvalid = null;
|
|
3192
|
+
this._handleClick = null;
|
|
3193
|
+
this.engine = engine;
|
|
3194
|
+
this.options = {
|
|
3195
|
+
focusChange: options.focusChange ?? true,
|
|
3196
|
+
formErrors: options.formErrors ?? true,
|
|
3197
|
+
navigation: options.navigation ?? true,
|
|
3198
|
+
announcements: options.announcements ?? true
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
/** Whether currently attached and listening */
|
|
3202
|
+
get isAttached() {
|
|
3203
|
+
return this._isAttached;
|
|
3204
|
+
}
|
|
3205
|
+
// ─── Attach / Detach ─────────────────────────────────────
|
|
3206
|
+
/**
|
|
3207
|
+
* Attach to a root element and begin listening for interactions.
|
|
3208
|
+
* Defaults to document.body if no root is provided.
|
|
3209
|
+
*/
|
|
3210
|
+
attach(root) {
|
|
3211
|
+
if (this._isAttached) return;
|
|
3212
|
+
if (typeof document === "undefined") return;
|
|
3213
|
+
this._root = root ?? document.body;
|
|
3214
|
+
if (!this._root) return;
|
|
3215
|
+
this._bindHandlers();
|
|
3216
|
+
this._attachListeners();
|
|
3217
|
+
this._startObserver();
|
|
3218
|
+
this._isAttached = true;
|
|
3219
|
+
}
|
|
3220
|
+
/** Remove all listeners and stop observing */
|
|
3221
|
+
detach() {
|
|
3222
|
+
if (!this._isAttached) return;
|
|
3223
|
+
this._removeListeners();
|
|
3224
|
+
this._stopObserver();
|
|
3225
|
+
this._isAttached = false;
|
|
3226
|
+
this._root = null;
|
|
3227
|
+
}
|
|
3228
|
+
// ─── Custom Handlers ─────────────────────────────────────
|
|
3229
|
+
/** Set a custom handler for focus changes */
|
|
3230
|
+
onFocusChange(callback) {
|
|
3231
|
+
this._focusChangeCallback = callback ?? null;
|
|
3232
|
+
}
|
|
3233
|
+
/** Set a custom handler for form errors */
|
|
3234
|
+
onFormError(callback) {
|
|
3235
|
+
this._formErrorCallback = callback ?? null;
|
|
3236
|
+
}
|
|
3237
|
+
// ─── Cleanup ─────────────────────────────────────────────
|
|
3238
|
+
/** Clean up all listeners, observers, and callbacks */
|
|
3239
|
+
dispose() {
|
|
3240
|
+
this.detach();
|
|
3241
|
+
this._focusChangeCallback = null;
|
|
3242
|
+
this._formErrorCallback = null;
|
|
3243
|
+
}
|
|
3244
|
+
// ─── Internal ────────────────────────────────────────────
|
|
3245
|
+
_bindHandlers() {
|
|
3246
|
+
this._handleFocusIn = (e) => {
|
|
3247
|
+
if (!this.options.focusChange) return;
|
|
3248
|
+
if (this._focusChangeCallback) {
|
|
3249
|
+
this._focusChangeCallback(e);
|
|
3250
|
+
return;
|
|
3251
|
+
}
|
|
3252
|
+
this._safeCall(() => this.engine.selection());
|
|
3253
|
+
};
|
|
3254
|
+
this._handleFocusOut = (_e) => {
|
|
3255
|
+
};
|
|
3256
|
+
this._handleInvalid = (e) => {
|
|
3257
|
+
if (!this.options.formErrors) return;
|
|
3258
|
+
if (this._formErrorCallback) {
|
|
3259
|
+
this._formErrorCallback(e);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
this._safeCall(() => this.engine.error());
|
|
3263
|
+
};
|
|
3264
|
+
this._handleClick = (e) => {
|
|
3265
|
+
const target = e.target;
|
|
3266
|
+
if (!target) return;
|
|
3267
|
+
const tagName = target.tagName?.toLowerCase();
|
|
3268
|
+
if (tagName === "button" || target.getAttribute?.("role") === "button") {
|
|
3269
|
+
this._safeCall(() => this.engine.tap());
|
|
3270
|
+
return;
|
|
3271
|
+
}
|
|
3272
|
+
if (tagName === "a" && this.options.navigation) {
|
|
3273
|
+
this._safeCall(() => this.engine.selection());
|
|
3274
|
+
return;
|
|
3275
|
+
}
|
|
3276
|
+
if (tagName === "input") {
|
|
3277
|
+
const inputType = target.type?.toLowerCase();
|
|
3278
|
+
if (inputType === "checkbox" || inputType === "radio") {
|
|
3279
|
+
const checked = target.checked;
|
|
3280
|
+
this._safeCall(() => this.engine.toggle(checked));
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
}
|
|
3285
|
+
_attachListeners() {
|
|
3286
|
+
if (!this._root) return;
|
|
3287
|
+
this._root.addEventListener("focusin", this._handleFocusIn, true);
|
|
3288
|
+
this._root.addEventListener("focusout", this._handleFocusOut, true);
|
|
3289
|
+
this._root.addEventListener("invalid", this._handleInvalid, true);
|
|
3290
|
+
this._root.addEventListener("click", this._handleClick, true);
|
|
3291
|
+
}
|
|
3292
|
+
_removeListeners() {
|
|
3293
|
+
if (!this._root) return;
|
|
3294
|
+
this._root.removeEventListener("focusin", this._handleFocusIn, true);
|
|
3295
|
+
this._root.removeEventListener("focusout", this._handleFocusOut, true);
|
|
3296
|
+
this._root.removeEventListener("invalid", this._handleInvalid, true);
|
|
3297
|
+
this._root.removeEventListener("click", this._handleClick, true);
|
|
3298
|
+
}
|
|
3299
|
+
_startObserver() {
|
|
3300
|
+
if (typeof MutationObserver === "undefined") return;
|
|
3301
|
+
if (!this.options.announcements) return;
|
|
3302
|
+
this._observer = new MutationObserver((mutations) => {
|
|
3303
|
+
for (const mutation of mutations) {
|
|
3304
|
+
for (const node of mutation.addedNodes) {
|
|
3305
|
+
if (node.nodeType !== 1) continue;
|
|
3306
|
+
const el = node;
|
|
3307
|
+
const role = el.getAttribute?.("role");
|
|
3308
|
+
const ariaLive = el.getAttribute?.("aria-live");
|
|
3309
|
+
if (role === "alert" || role === "alertdialog") {
|
|
3310
|
+
this._safeCall(() => this.engine.warning());
|
|
3311
|
+
} else if (role === "status" || ariaLive === "polite" || ariaLive === "assertive") {
|
|
3312
|
+
this._safeCall(() => this.engine.selection());
|
|
3313
|
+
}
|
|
3314
|
+
if (role === "dialog" || el.tagName?.toLowerCase() === "dialog") {
|
|
3315
|
+
this._safeCall(() => this.engine.toggle(true));
|
|
3316
|
+
}
|
|
3317
|
+
}
|
|
3318
|
+
for (const node of mutation.removedNodes) {
|
|
3319
|
+
if (node.nodeType !== 1) continue;
|
|
3320
|
+
const el = node;
|
|
3321
|
+
const role = el.getAttribute?.("role");
|
|
3322
|
+
if (role === "dialog" || el.tagName?.toLowerCase() === "dialog") {
|
|
3323
|
+
this._safeCall(() => this.engine.toggle(false));
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
}
|
|
3327
|
+
});
|
|
3328
|
+
this._observer.observe(this._root, {
|
|
3329
|
+
childList: true,
|
|
3330
|
+
subtree: true
|
|
3331
|
+
});
|
|
3332
|
+
}
|
|
3333
|
+
_stopObserver() {
|
|
3334
|
+
if (this._observer) {
|
|
3335
|
+
this._observer.disconnect();
|
|
3336
|
+
this._observer = null;
|
|
3337
|
+
}
|
|
3338
|
+
}
|
|
3339
|
+
_safeCall(fn) {
|
|
3340
|
+
try {
|
|
3341
|
+
const result = fn();
|
|
3342
|
+
if (result && typeof result.catch === "function") {
|
|
3343
|
+
result.catch(() => {
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
} catch {
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
};
|
|
3350
|
+
|
|
2623
3351
|
// src/index.ts
|
|
2624
3352
|
var haptic = HapticEngine.create();
|
|
2625
3353
|
|
|
2626
|
-
export { AdaptiveEngine, FallbackManager, HPLParser, HPLParserError, HPLTokenizerError, HapticEngine, IoSAudioAdapter, NoopAdapter, PatternComposer, PatternRecorder, SensoryEngine, SoundEngine, ThemeManager, VisualEngine, WebVibrationAdapter, accessibility, bounce, compile, detectAdapter, detectPlatform, elastic, emotions, exportPattern, friction, gaming, gravity, haptic, impact, importPattern, notifications, optimizeSteps, parseHPL, patternFromDataURL, patternFromJSON, patternToDataURL, patternToJSON, pendulum, physics, presets, spring, system, themes, tokenize, ui, validateHPL, wave };
|
|
3354
|
+
export { AdaptiveEngine, FallbackManager, HPLParser, HPLParserError, HPLTokenizerError, HapticA11y, HapticEngine, HapticExperiment, IoSAudioAdapter, MiddlewareManager, MotionDetector, NoopAdapter, PatternComposer, PatternRecorder, ProfileManager, RhythmSync, SensoryEngine, SoundEngine, ThemeManager, VisualEngine, WebVibrationAdapter, accessibility, accessibilityBooster, bounce, compile, detectAdapter, detectPlatform, durationScaler, elastic, emotions, exportPattern, friction, gaming, gravity, haptic, impact, importPattern, intensityClamper, intensityScaler, notifications, optimizeSteps, parseHPL, patternFromDataURL, patternFromJSON, patternRepeater, patternToDataURL, patternToJSON, pendulum, physics, presets, profiles, reverser, spring, system, themes, tokenize, ui, validateHPL, wave };
|
|
2627
3355
|
//# sourceMappingURL=index.js.map
|
|
2628
3356
|
//# sourceMappingURL=index.js.map
|