@gratiaos/presence-kernel 1.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 +243 -0
- package/README.md +166 -0
- package/dist/ConstellationHUD.d.ts +18 -0
- package/dist/ConstellationHUD.d.ts.map +1 -0
- package/dist/ConstellationHUD.js +103 -0
- package/dist/ConstellationHUD.js.map +1 -0
- package/dist/Heartbeat.d.ts +4 -0
- package/dist/Heartbeat.d.ts.map +1 -0
- package/dist/Heartbeat.js +161 -0
- package/dist/Heartbeat.js.map +1 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +140 -0
- package/dist/index.js.map +1 -0
- package/dist/phase-sound-profile.d.ts +9 -0
- package/dist/phase-sound-profile.d.ts.map +1 -0
- package/dist/phase-sound-profile.js +8 -0
- package/dist/phase-sound-profile.js.map +1 -0
- package/dist/useConstellationAudio.d.ts +9 -0
- package/dist/useConstellationAudio.d.ts.map +1 -0
- package/dist/useConstellationAudio.js +16 -0
- package/dist/useConstellationAudio.js.map +1 -0
- package/dist/usePhaseSound.d.ts +2 -0
- package/dist/usePhaseSound.d.ts.map +1 -0
- package/dist/usePhaseSound.js +137 -0
- package/dist/usePhaseSound.js.map +1 -0
- package/dist/usePhaseSpatialSound.d.ts +2 -0
- package/dist/usePhaseSpatialSound.d.ts.map +1 -0
- package/dist/usePhaseSpatialSound.js +206 -0
- package/dist/usePhaseSpatialSound.js.map +1 -0
- package/package.json +56 -0
- package/src/ConstellationHUD.tsx +136 -0
- package/src/Heartbeat.tsx +178 -0
- package/src/constellation-hud.css +101 -0
- package/src/heartbeat.css +75 -0
- package/src/index.ts +177 -0
- package/src/phase-sound-profile.ts +15 -0
- package/src/useConstellationAudio.ts +16 -0
- package/src/usePhaseSound.ts +163 -0
- package/src/usePhaseSpatialSound.ts +237 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import { phase$, peers$, pulse$ } from './index';
|
|
3
|
+
import { DEFAULT_SOUND_PROFILE, PHASE_SOUND_PROFILE } from './phase-sound-profile';
|
|
4
|
+
const LISTEN_EVENTS = ['pointerdown', 'keydown', 'touchstart'];
|
|
5
|
+
const BASE_GAIN = 0.14;
|
|
6
|
+
const filterFrequency = (type) => {
|
|
7
|
+
switch (type) {
|
|
8
|
+
case 'lowpass':
|
|
9
|
+
return 720;
|
|
10
|
+
case 'bandpass':
|
|
11
|
+
return 1500;
|
|
12
|
+
case 'highpass':
|
|
13
|
+
return 2600;
|
|
14
|
+
case 'notch':
|
|
15
|
+
return 1800;
|
|
16
|
+
default:
|
|
17
|
+
return 1200;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const hashCode = (input) => Array.from(input).reduce((acc, char, index) => acc + char.charCodeAt(0) * (index + 1), 0);
|
|
21
|
+
// Cache per peer so we only compute hash + derived pan/detune once per id.
|
|
22
|
+
// Peer IDs are stable for a session; memory footprint is minimal.
|
|
23
|
+
const peerMetricsCache = new Map();
|
|
24
|
+
const getPeerMetrics = (peerId) => {
|
|
25
|
+
let cached = peerMetricsCache.get(peerId);
|
|
26
|
+
if (cached)
|
|
27
|
+
return cached;
|
|
28
|
+
const hash = hashCode(peerId);
|
|
29
|
+
const pan = Math.max(-0.85, Math.min(0.85, Math.sin(hash)));
|
|
30
|
+
const semitone = (hash % 9) - 4; // discrete integral offset in [-4,4]
|
|
31
|
+
// Micro‑detune per peer:
|
|
32
|
+
// Divide by 24 → range ≈ [-0.1667, +0.1667] semitones (± ~16.7 cents ≈ one sixth of a semitone).
|
|
33
|
+
// This is a subtle colorization — previously commented as "quarter‑tone" which would be ~50 cents; kept subtle.
|
|
34
|
+
const detune = semitone / 24;
|
|
35
|
+
cached = { hash, pan, detune };
|
|
36
|
+
peerMetricsCache.set(peerId, cached);
|
|
37
|
+
return cached;
|
|
38
|
+
};
|
|
39
|
+
const gainForPeer = (pan) => {
|
|
40
|
+
const centerBoost = 1 - Math.min(Math.abs(pan), 0.85) * 0.45;
|
|
41
|
+
return 0.028 + centerBoost * 0.018;
|
|
42
|
+
};
|
|
43
|
+
const mergePeers = (ids, selfId) => {
|
|
44
|
+
const cleaned = ids.filter(Boolean);
|
|
45
|
+
const unique = Array.from(new Set(cleaned));
|
|
46
|
+
if (selfId && !unique.includes(selfId))
|
|
47
|
+
unique.unshift(selfId);
|
|
48
|
+
return unique;
|
|
49
|
+
};
|
|
50
|
+
export function usePhaseSpatialSound(selfId, enabled = true) {
|
|
51
|
+
const ctxRef = useRef(null);
|
|
52
|
+
const masterRef = useRef(null);
|
|
53
|
+
const readyRef = useRef(false);
|
|
54
|
+
const pendingRef = useRef([]);
|
|
55
|
+
const peersRef = useRef(mergePeers(peers$.value, selfId));
|
|
56
|
+
const profileRef = useRef(DEFAULT_SOUND_PROFILE);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!enabled)
|
|
59
|
+
return; // gated; cleanup occurs when toggling from enabled→false
|
|
60
|
+
if (typeof window === 'undefined')
|
|
61
|
+
return;
|
|
62
|
+
const win = window;
|
|
63
|
+
const ensureMaster = () => {
|
|
64
|
+
const ctx = ctxRef.current;
|
|
65
|
+
if (!ctx)
|
|
66
|
+
return false;
|
|
67
|
+
if (!masterRef.current) {
|
|
68
|
+
const master = ctx.createGain();
|
|
69
|
+
master.gain.value = BASE_GAIN;
|
|
70
|
+
master.connect(ctx.destination);
|
|
71
|
+
masterRef.current = master;
|
|
72
|
+
}
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
const flushPending = () => {
|
|
76
|
+
if (!readyRef.current || !ensureMaster())
|
|
77
|
+
return;
|
|
78
|
+
const queue = pendingRef.current.splice(0, pendingRef.current.length);
|
|
79
|
+
queue.forEach((fn) => fn());
|
|
80
|
+
};
|
|
81
|
+
const ensureContext = (allowResume) => {
|
|
82
|
+
var _a;
|
|
83
|
+
const AudioCtx = (_a = win.AudioContext) !== null && _a !== void 0 ? _a : win.webkitAudioContext;
|
|
84
|
+
if (!AudioCtx)
|
|
85
|
+
return false;
|
|
86
|
+
try {
|
|
87
|
+
if (!ctxRef.current) {
|
|
88
|
+
ctxRef.current = new AudioCtx();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const ctx = ctxRef.current;
|
|
95
|
+
if (!ctx)
|
|
96
|
+
return false;
|
|
97
|
+
if (ctx.state === 'suspended') {
|
|
98
|
+
if (!allowResume)
|
|
99
|
+
return false;
|
|
100
|
+
void ctx
|
|
101
|
+
.resume()
|
|
102
|
+
.then(() => {
|
|
103
|
+
readyRef.current = true;
|
|
104
|
+
ensureMaster();
|
|
105
|
+
flushPending();
|
|
106
|
+
})
|
|
107
|
+
.catch(() => {
|
|
108
|
+
/* still locked */
|
|
109
|
+
});
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
readyRef.current = true;
|
|
113
|
+
ensureMaster();
|
|
114
|
+
flushPending();
|
|
115
|
+
return true;
|
|
116
|
+
};
|
|
117
|
+
const queuePlayback = (fn) => {
|
|
118
|
+
if (!readyRef.current && !ensureContext(false)) {
|
|
119
|
+
pendingRef.current.push(fn);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
readyRef.current = true;
|
|
123
|
+
if (!ensureMaster()) {
|
|
124
|
+
pendingRef.current.push(fn);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
fn();
|
|
128
|
+
};
|
|
129
|
+
const unlock = () => {
|
|
130
|
+
if (ensureContext(true)) {
|
|
131
|
+
readyRef.current = true;
|
|
132
|
+
LISTEN_EVENTS.forEach((event) => win.removeEventListener(event, unlock));
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
LISTEN_EVENTS.forEach((event) => win.addEventListener(event, unlock, { passive: true }));
|
|
136
|
+
ensureContext(false);
|
|
137
|
+
const playPeerTone = (peerId, profile, index) => {
|
|
138
|
+
const base = profile.root;
|
|
139
|
+
const { detune, pan: rawPan } = getPeerMetrics(peerId);
|
|
140
|
+
const frequency = base * Math.pow(2, detune);
|
|
141
|
+
const pan = peerId === selfId ? 0 : rawPan;
|
|
142
|
+
const gainAmount = gainForPeer(pan);
|
|
143
|
+
const delay = index * 0.045;
|
|
144
|
+
queuePlayback(() => {
|
|
145
|
+
const ctx = ctxRef.current;
|
|
146
|
+
const master = masterRef.current;
|
|
147
|
+
if (!ctx || !master)
|
|
148
|
+
return;
|
|
149
|
+
const start = ctx.currentTime + delay;
|
|
150
|
+
const osc = ctx.createOscillator();
|
|
151
|
+
const filter = ctx.createBiquadFilter();
|
|
152
|
+
const panner = ctx.createStereoPanner();
|
|
153
|
+
const gain = ctx.createGain();
|
|
154
|
+
osc.type = 'sine';
|
|
155
|
+
osc.frequency.setValueAtTime(frequency, start);
|
|
156
|
+
filter.type = profile.filter;
|
|
157
|
+
filter.frequency.setValueAtTime(filterFrequency(profile.filter), start);
|
|
158
|
+
panner.pan.setValueAtTime(pan, start);
|
|
159
|
+
gain.gain.setValueAtTime(gainAmount, start);
|
|
160
|
+
gain.gain.exponentialRampToValueAtTime(0.001, start + 0.24);
|
|
161
|
+
osc.connect(filter);
|
|
162
|
+
filter.connect(panner);
|
|
163
|
+
panner.connect(gain);
|
|
164
|
+
gain.connect(master);
|
|
165
|
+
osc.start(start);
|
|
166
|
+
osc.stop(start + 0.26);
|
|
167
|
+
});
|
|
168
|
+
};
|
|
169
|
+
const pulsePeers = (profile) => {
|
|
170
|
+
const peers = peersRef.current.length ? [...peersRef.current] : selfId ? [selfId] : [];
|
|
171
|
+
peers.forEach((peerId, index) => playPeerTone(peerId, profile, index));
|
|
172
|
+
};
|
|
173
|
+
const phaseSub = phase$.subscribe((phase) => {
|
|
174
|
+
var _a;
|
|
175
|
+
const profile = (_a = PHASE_SOUND_PROFILE[phase]) !== null && _a !== void 0 ? _a : DEFAULT_SOUND_PROFILE;
|
|
176
|
+
profileRef.current = profile;
|
|
177
|
+
});
|
|
178
|
+
const peersSub = peers$.subscribe((ids) => {
|
|
179
|
+
peersRef.current = mergePeers(ids, selfId);
|
|
180
|
+
});
|
|
181
|
+
let firstPulse = true;
|
|
182
|
+
const pulseSub = pulse$.subscribe(() => {
|
|
183
|
+
if (firstPulse) {
|
|
184
|
+
firstPulse = false;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
pulsePeers(profileRef.current);
|
|
188
|
+
});
|
|
189
|
+
return () => {
|
|
190
|
+
phaseSub();
|
|
191
|
+
peersSub();
|
|
192
|
+
pulseSub();
|
|
193
|
+
LISTEN_EVENTS.forEach((event) => win.removeEventListener(event, unlock));
|
|
194
|
+
pendingRef.current = [];
|
|
195
|
+
readyRef.current = false;
|
|
196
|
+
masterRef.current = null;
|
|
197
|
+
if (ctxRef.current) {
|
|
198
|
+
void ctxRef.current.close().catch(() => {
|
|
199
|
+
/* ignore */
|
|
200
|
+
});
|
|
201
|
+
ctxRef.current = null;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}, [selfId, enabled]);
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=usePhaseSpatialSound.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"usePhaseSpatialSound.js","sourceRoot":"","sources":["../src/usePhaseSpatialSound.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gratiaos/presence-kernel",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"funding": "https://github.com/sponsors/GratiaOS",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "AGPL-3.0-only",
|
|
7
|
+
"description": "Shared presence kernel signals for Gratia OS surfaces.",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/GratiaOS/garden-core.git",
|
|
11
|
+
"directory": "packages/presence-kernel"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"sideEffects": [
|
|
28
|
+
"./src/heartbeat.css",
|
|
29
|
+
"./src/constellation-hud.css"
|
|
30
|
+
],
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^18 || ^19"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc -p tsconfig.json",
|
|
36
|
+
"clean": "rimraf dist"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@gratiaos/signal": "workspace:*"
|
|
40
|
+
},
|
|
41
|
+
"keywords": [
|
|
42
|
+
"presence",
|
|
43
|
+
"signals",
|
|
44
|
+
"realtime",
|
|
45
|
+
"kernel",
|
|
46
|
+
"gratia",
|
|
47
|
+
"garden",
|
|
48
|
+
"a11y"
|
|
49
|
+
],
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"react": "^19.2.0",
|
|
52
|
+
"@types/react": "^19.2.2",
|
|
53
|
+
"typescript": "^5.9.2",
|
|
54
|
+
"rimraf": "^6.0.1"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
|
2
|
+
import { phase$, peers$, pulse$, type Phase } from './index';
|
|
3
|
+
import './constellation-hud.css';
|
|
4
|
+
import { useConstellationAudio } from './useConstellationAudio';
|
|
5
|
+
|
|
6
|
+
const BASE_RADIUS = 42;
|
|
7
|
+
const RING_GAP = 18;
|
|
8
|
+
const RING_CAPACITY = 10;
|
|
9
|
+
|
|
10
|
+
const hueFromId = (id: string, index: number) => {
|
|
11
|
+
const sum = Array.from(id).reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
12
|
+
return (sum + index * 37) % 360;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ConstellationHUD — visual + optional audio pulse for presence peers.
|
|
17
|
+
*
|
|
18
|
+
* Audio duplication guard: previously both `usePhaseSpatialSound` and `usePhaseSound`
|
|
19
|
+
* were invoked unconditionally, causing layered playback on each pulse. We now gate
|
|
20
|
+
* invocation with `soundMode`:
|
|
21
|
+
* • 'spatial' (default) → spatial panning + micro-detune per peer.
|
|
22
|
+
* • 'phase' → single phase tone without spatial layering.
|
|
23
|
+
* • 'both' → preserves legacy behavior (two overlapping systems).
|
|
24
|
+
* • 'none' → suppress audio entirely (visual only).
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* @param soundMode Controls audio playback mode:
|
|
28
|
+
* - 'spatial': spatial panning + micro-detune per peer (default)
|
|
29
|
+
* - 'phase': single phase tone without spatial layering
|
|
30
|
+
* - 'both': legacy behavior with spatial + phase layers stacked
|
|
31
|
+
* - 'none': silence all audio, leaving visuals only
|
|
32
|
+
*/
|
|
33
|
+
// Unified audio hook (replaces wrapper components). Underlying hooks are gated
|
|
34
|
+
// via enabled flags; we avoid extra component indirection while keeping Rules of Hooks.
|
|
35
|
+
|
|
36
|
+
export const ConstellationHUD: React.FC<{ selfId?: string; soundMode?: 'spatial' | 'phase' | 'both' | 'none' }> = ({
|
|
37
|
+
selfId,
|
|
38
|
+
soundMode = 'spatial',
|
|
39
|
+
}) => {
|
|
40
|
+
const [phase, setPhase] = useState<Phase>(phase$.value);
|
|
41
|
+
const [peerIds, setPeerIds] = useState<string[]>(() => [...peers$.value]);
|
|
42
|
+
const [pulseActive, setPulseActive] = useState(false);
|
|
43
|
+
const pulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
44
|
+
|
|
45
|
+
// Render audio wrappers conditionally (hooks live inside wrapper components).
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const stopPhase = phase$.subscribe((nextPhase) => setPhase(nextPhase));
|
|
49
|
+
const stopPeers = peers$.subscribe((ids) => setPeerIds(ids));
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
stopPhase();
|
|
53
|
+
stopPeers();
|
|
54
|
+
if (pulseTimeoutRef.current) {
|
|
55
|
+
window.clearTimeout(pulseTimeoutRef.current);
|
|
56
|
+
pulseTimeoutRef.current = null;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (typeof window === 'undefined') return;
|
|
63
|
+
let first = true;
|
|
64
|
+
const stop = pulse$.subscribe(() => {
|
|
65
|
+
if (first) {
|
|
66
|
+
first = false;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
setPulseActive(true);
|
|
70
|
+
if (pulseTimeoutRef.current) {
|
|
71
|
+
window.clearTimeout(pulseTimeoutRef.current);
|
|
72
|
+
}
|
|
73
|
+
pulseTimeoutRef.current = window.setTimeout(() => {
|
|
74
|
+
setPulseActive(false);
|
|
75
|
+
pulseTimeoutRef.current = null;
|
|
76
|
+
}, 520);
|
|
77
|
+
});
|
|
78
|
+
return () => {
|
|
79
|
+
stop();
|
|
80
|
+
if (pulseTimeoutRef.current) {
|
|
81
|
+
window.clearTimeout(pulseTimeoutRef.current);
|
|
82
|
+
pulseTimeoutRef.current = null;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const peers = useMemo(() => {
|
|
88
|
+
const sanitized = peerIds
|
|
89
|
+
.map((id) => id.trim())
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.sort((a, b) => a.localeCompare(b));
|
|
92
|
+
|
|
93
|
+
const rings: string[][] = [];
|
|
94
|
+
sanitized.forEach((id, index) => {
|
|
95
|
+
const ringIndex = Math.floor(index / RING_CAPACITY);
|
|
96
|
+
if (!rings[ringIndex]) rings[ringIndex] = [];
|
|
97
|
+
rings[ringIndex].push(id);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return rings.flatMap((ringPeers, ringIndex) => {
|
|
101
|
+
const radius = BASE_RADIUS + ringIndex * RING_GAP;
|
|
102
|
+
const slice = 360 / Math.max(ringPeers.length, 1);
|
|
103
|
+
return ringPeers.map((id, indexInRing) => {
|
|
104
|
+
const angle = slice * indexInRing;
|
|
105
|
+
return {
|
|
106
|
+
id,
|
|
107
|
+
transform: `translate(-50%, -50%) rotate(${angle}deg) translateX(${radius}px)`,
|
|
108
|
+
hue: hueFromId(id, ringIndex + indexInRing),
|
|
109
|
+
label: id,
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}, [peerIds]);
|
|
114
|
+
|
|
115
|
+
// Invoke unified audio hook (stable call order).
|
|
116
|
+
useConstellationAudio(soundMode, selfId);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="constellation-hud" data-count={peers.length} data-empty={peers.length === 0 || undefined}>
|
|
120
|
+
<div className={`peer-core ${pulseActive ? 'pulsing' : ''}`} style={{ background: `var(--color-${phase})` }} title={`local • ${phase}`} />
|
|
121
|
+
{peers.map((peer) => (
|
|
122
|
+
<div
|
|
123
|
+
key={peer.id}
|
|
124
|
+
className={`peer-dot ${pulseActive ? 'pulsing' : ''}`}
|
|
125
|
+
style={
|
|
126
|
+
{
|
|
127
|
+
background: `hsl(${peer.hue}, 65%, 60%)`,
|
|
128
|
+
'--orbit-transform': peer.transform,
|
|
129
|
+
} as CSSProperties & { ['--orbit-transform']: string }
|
|
130
|
+
}
|
|
131
|
+
title={peer.label}
|
|
132
|
+
/>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { phase$, mood$, peers$, pulse$, type Phase, type Mood } from './index';
|
|
3
|
+
import './heartbeat.css';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_COLOR = '#ffffff';
|
|
6
|
+
|
|
7
|
+
function resolveColor(color: string): [number, number, number] {
|
|
8
|
+
if (typeof document === 'undefined') return [255, 255, 255];
|
|
9
|
+
const element = document.createElement('span');
|
|
10
|
+
element.style.color = color;
|
|
11
|
+
document.body.appendChild(element);
|
|
12
|
+
const computed = getComputedStyle(element).color;
|
|
13
|
+
document.body.removeChild(element);
|
|
14
|
+
const match = computed.match(/\d+/g)?.map(Number);
|
|
15
|
+
if (!match || match.length < 3) return [255, 255, 255];
|
|
16
|
+
return [match[0] ?? 255, match[1] ?? 255, match[2] ?? 255];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function rgbToHsl(r: number, g: number, b: number) {
|
|
20
|
+
r /= 255;
|
|
21
|
+
g /= 255;
|
|
22
|
+
b /= 255;
|
|
23
|
+
const max = Math.max(r, g, b);
|
|
24
|
+
const min = Math.min(r, g, b);
|
|
25
|
+
let h = 0;
|
|
26
|
+
let s = 0;
|
|
27
|
+
const l = (max + min) / 2;
|
|
28
|
+
|
|
29
|
+
if (max !== min) {
|
|
30
|
+
const d = max - min;
|
|
31
|
+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
32
|
+
switch (max) {
|
|
33
|
+
case r:
|
|
34
|
+
h = (g - b) / d + (g < b ? 6 : 0);
|
|
35
|
+
break;
|
|
36
|
+
case g:
|
|
37
|
+
h = (b - r) / d + 2;
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
h = (r - g) / d + 4;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
h *= 60;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { h, s: s * 100, l: l * 100 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function amplitudeForMood(mood: Mood) {
|
|
50
|
+
if (mood === 'celebratory') return 4;
|
|
51
|
+
if (mood === 'presence') return 3;
|
|
52
|
+
if (mood === 'focused') return 2.5;
|
|
53
|
+
if (mood === 'soft') return 1.8;
|
|
54
|
+
return 1.5;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const Heartbeat: React.FC = () => {
|
|
58
|
+
const [phase, setPhase] = useState<Phase>(phase$.value);
|
|
59
|
+
const [mood, setMood] = useState<Mood>(mood$.value);
|
|
60
|
+
const [peerWaves, setPeerWaves] = useState<string[]>(peers$.value);
|
|
61
|
+
const [pulseActive, setPulseActive] = useState(false);
|
|
62
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
63
|
+
const pulseTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const stopPhase = phase$.subscribe(setPhase);
|
|
67
|
+
const stopMood = mood$.subscribe(setMood);
|
|
68
|
+
const stopPeers = peers$.subscribe(setPeerWaves);
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
stopPhase();
|
|
72
|
+
stopMood();
|
|
73
|
+
stopPeers();
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (typeof window === 'undefined') return;
|
|
79
|
+
let first = true;
|
|
80
|
+
const stop = pulse$.subscribe(() => {
|
|
81
|
+
if (first) {
|
|
82
|
+
first = false;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setPulseActive(true);
|
|
86
|
+
if (pulseTimeoutRef.current) {
|
|
87
|
+
window.clearTimeout(pulseTimeoutRef.current);
|
|
88
|
+
}
|
|
89
|
+
pulseTimeoutRef.current = window.setTimeout(() => {
|
|
90
|
+
setPulseActive(false);
|
|
91
|
+
pulseTimeoutRef.current = null;
|
|
92
|
+
}, 520);
|
|
93
|
+
});
|
|
94
|
+
return () => {
|
|
95
|
+
stop();
|
|
96
|
+
if (pulseTimeoutRef.current) {
|
|
97
|
+
window.clearTimeout(pulseTimeoutRef.current);
|
|
98
|
+
pulseTimeoutRef.current = null;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
105
|
+
const canvas = canvasRef.current;
|
|
106
|
+
if (!canvas) return;
|
|
107
|
+
const ctx = canvas.getContext('2d');
|
|
108
|
+
if (!ctx) return;
|
|
109
|
+
|
|
110
|
+
let t = 0;
|
|
111
|
+
let frame: number;
|
|
112
|
+
|
|
113
|
+
const draw = () => {
|
|
114
|
+
const { width, height } = canvas;
|
|
115
|
+
ctx.clearRect(0, 0, width, height);
|
|
116
|
+
|
|
117
|
+
const computed = getComputedStyle(document.documentElement);
|
|
118
|
+
const strokeValue = computed.getPropertyValue(`--color-${phase}`).trim() || DEFAULT_COLOR;
|
|
119
|
+
const [r, g, b] = resolveColor(strokeValue);
|
|
120
|
+
const { h: baseHue, s: baseSat, l: baseLight } = rgbToHsl(r, g, b);
|
|
121
|
+
const mainColor = `hsl(${baseHue}, ${baseSat}%, ${baseLight}%)`;
|
|
122
|
+
|
|
123
|
+
const drawWave = (color: string, amplitude: number, phaseOffset = 0, opacity = 1) => {
|
|
124
|
+
ctx.save();
|
|
125
|
+
ctx.globalAlpha = opacity;
|
|
126
|
+
ctx.strokeStyle = color;
|
|
127
|
+
ctx.lineWidth = 1.5;
|
|
128
|
+
ctx.beginPath();
|
|
129
|
+
for (let x = 0; x < width; x++) {
|
|
130
|
+
const y = height / 2 + Math.sin((x + t + phaseOffset) * 0.06) * amplitude;
|
|
131
|
+
if (x === 0) ctx.moveTo(x, y);
|
|
132
|
+
else ctx.lineTo(x, y);
|
|
133
|
+
}
|
|
134
|
+
ctx.stroke();
|
|
135
|
+
ctx.restore();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
drawWave(mainColor, 5 + amplitudeForMood(mood));
|
|
139
|
+
|
|
140
|
+
const hueDrift = (offset: number) => (Date.now() / 1000 / 8 + offset) % 360;
|
|
141
|
+
|
|
142
|
+
peerWaves.forEach((peerId, index) => {
|
|
143
|
+
const baseOffset = Array.from(peerId).reduce((sum, char) => sum + char.charCodeAt(0), 0) + index * 45;
|
|
144
|
+
const peerHue = (baseOffset + hueDrift(index)) % 360;
|
|
145
|
+
const peerColor = `hsl(${peerHue}, ${Math.min(baseSat + 10, 90)}%, ${Math.min(baseLight + 5, 70)}%)`;
|
|
146
|
+
drawWave(peerColor, 3 + amplitudeForMood(mood) * 0.6, index * 20, 0.4);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
t += 2;
|
|
150
|
+
frame = requestAnimationFrame(draw);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
draw();
|
|
154
|
+
return () => cancelAnimationFrame(frame);
|
|
155
|
+
}, [phase, mood, peerWaves]);
|
|
156
|
+
|
|
157
|
+
const color = `var(--color-${phase}, var(--color-${mood}, var(--color-accent)))`;
|
|
158
|
+
const scale = pulseActive ? 1.2 : 1;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div className={`heartbeat-wrapper ${pulseActive ? 'pulse-on' : ''}`} style={{ color }} title={`phase: ${phase}, mood: ${mood}`}>
|
|
162
|
+
<div
|
|
163
|
+
className="heartbeat"
|
|
164
|
+
style={{
|
|
165
|
+
width: '16px',
|
|
166
|
+
height: '16px',
|
|
167
|
+
backgroundColor: color,
|
|
168
|
+
transform: `scale(${scale})`,
|
|
169
|
+
transition: 'transform 0.3s ease, background 0.6s ease',
|
|
170
|
+
boxShadow: `0 0 12px ${color}`,
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
173
|
+
<span className="heartbeat-ring" style={{ borderColor: color }} />
|
|
174
|
+
<span className="heartbeat-ring echo" style={{ borderColor: color }} />
|
|
175
|
+
<canvas ref={canvasRef} width={80} height={16} className="heartbeat-wave" />
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
.constellation-hud {
|
|
2
|
+
position: fixed;
|
|
3
|
+
bottom: 2rem;
|
|
4
|
+
right: 2rem;
|
|
5
|
+
width: 124px;
|
|
6
|
+
height: 124px;
|
|
7
|
+
border-radius: 50%;
|
|
8
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
9
|
+
display: flex;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
align-items: center;
|
|
12
|
+
pointer-events: none;
|
|
13
|
+
opacity: 0.9;
|
|
14
|
+
backdrop-filter: blur(18px) saturate(115%);
|
|
15
|
+
animation: constellation-spin 60s linear infinite;
|
|
16
|
+
z-index: 95;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.constellation-hud::after {
|
|
20
|
+
content: '';
|
|
21
|
+
position: absolute;
|
|
22
|
+
inset: 10%;
|
|
23
|
+
border-radius: 50%;
|
|
24
|
+
background: radial-gradient(closest-side, rgba(255, 255, 255, 0.12), transparent);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.constellation-hud[data-empty='true'] {
|
|
28
|
+
opacity: 0.6;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.peer-core {
|
|
32
|
+
width: 14px;
|
|
33
|
+
height: 14px;
|
|
34
|
+
border-radius: 9999px;
|
|
35
|
+
box-shadow: 0 0 14px currentColor;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.peer-core.pulsing {
|
|
39
|
+
animation: phase-core-pulse 0.8s ease-out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.peer-dot {
|
|
43
|
+
--orbit-transform: translate(-50%, -50%);
|
|
44
|
+
position: absolute;
|
|
45
|
+
top: 50%;
|
|
46
|
+
left: 50%;
|
|
47
|
+
width: 8px;
|
|
48
|
+
height: 8px;
|
|
49
|
+
border-radius: 9999px;
|
|
50
|
+
transform: var(--orbit-transform);
|
|
51
|
+
box-shadow: 0 0 8px currentColor;
|
|
52
|
+
opacity: 0.75;
|
|
53
|
+
transition: opacity 0.45s ease, transform 0.45s ease;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.peer-dot.pulsing {
|
|
57
|
+
animation: phase-dot-pulse 0.8s ease-out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.constellation-hud:hover .peer-dot {
|
|
61
|
+
opacity: 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@keyframes constellation-spin {
|
|
65
|
+
from {
|
|
66
|
+
transform: rotate(0deg);
|
|
67
|
+
}
|
|
68
|
+
to {
|
|
69
|
+
transform: rotate(360deg);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@keyframes phase-core-pulse {
|
|
74
|
+
0% {
|
|
75
|
+
transform: scale(1);
|
|
76
|
+
box-shadow: 0 0 12px currentColor;
|
|
77
|
+
}
|
|
78
|
+
40% {
|
|
79
|
+
transform: scale(1.35);
|
|
80
|
+
box-shadow: 0 0 20px currentColor;
|
|
81
|
+
}
|
|
82
|
+
100% {
|
|
83
|
+
transform: scale(1);
|
|
84
|
+
box-shadow: 0 0 12px currentColor;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@keyframes phase-dot-pulse {
|
|
89
|
+
0% {
|
|
90
|
+
transform: var(--orbit-transform);
|
|
91
|
+
box-shadow: 0 0 8px currentColor;
|
|
92
|
+
}
|
|
93
|
+
40% {
|
|
94
|
+
transform: var(--orbit-transform) scale(1.25);
|
|
95
|
+
box-shadow: 0 0 16px currentColor;
|
|
96
|
+
}
|
|
97
|
+
100% {
|
|
98
|
+
transform: var(--orbit-transform);
|
|
99
|
+
box-shadow: 0 0 8px currentColor;
|
|
100
|
+
}
|
|
101
|
+
}
|