@elmahdistudio/zenith-core 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/dist/arc/arc.d.ts +20 -0
- package/dist/arc/arc.js +48 -0
- package/dist/audio/ambient.d.ts +11 -0
- package/dist/audio/ambient.js +174 -0
- package/dist/audio/chime.d.ts +5 -0
- package/dist/audio/chime.js +47 -0
- package/dist/celestial/celestial.d.ts +3 -0
- package/dist/celestial/celestial.js +30 -0
- package/dist/constellation/constellations.d.ts +3 -0
- package/dist/constellation/constellations.js +90 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/timer/timer.d.ts +21 -0
- package/dist/timer/timer.js +106 -0
- package/dist/types/index.d.ts +51 -0
- package/dist/types/index.js +1 -0
- package/package.json +25 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Convert polar angle (degrees, 0 = top, clockwise) to cartesian coordinates */
|
|
2
|
+
export declare function polarToXY(cx: number, cy: number, r: number, deg: number): {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
/** Map minutes (5-60) to angle (0-360) for the timer ring */
|
|
7
|
+
export declare function minuteToAngle(m: number): number;
|
|
8
|
+
/** Map angle (0-360) to the nearest snap interval in minutes */
|
|
9
|
+
export declare function angleToMinute(deg: number): number;
|
|
10
|
+
/** Map a 0-1 progress value to a point on a semi-elliptical sundial arc */
|
|
11
|
+
export declare function pointOnSundialArc(t: number, cx: number, cy: number, arcWidth: number, arcHeight: number): {
|
|
12
|
+
x: number;
|
|
13
|
+
y: number;
|
|
14
|
+
};
|
|
15
|
+
/** Build an SVG arc path for the timer ring */
|
|
16
|
+
export declare function buildArcPath(cx: number, cy: number, radius: number, angleDeg: number): string;
|
|
17
|
+
/** Build a full circle SVG path */
|
|
18
|
+
export declare function buildFullCirclePath(cx: number, cy: number, radius: number): string;
|
|
19
|
+
/** Build the sundial arc SVG path from 40 segments */
|
|
20
|
+
export declare function buildSundialArcPath(cx: number, cy: number, arcWidth: number, arcHeight: number, segments?: number): string;
|
package/dist/arc/arc.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { SNAP_INTERVALS } from "../timer/timer.js";
|
|
2
|
+
/** Convert polar angle (degrees, 0 = top, clockwise) to cartesian coordinates */
|
|
3
|
+
export function polarToXY(cx, cy, r, deg) {
|
|
4
|
+
const rad = ((deg - 90) * Math.PI) / 180;
|
|
5
|
+
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
|
6
|
+
}
|
|
7
|
+
/** Map minutes (5-60) to angle (0-360) for the timer ring */
|
|
8
|
+
export function minuteToAngle(m) {
|
|
9
|
+
return ((m - 5) / (60 - 5)) * 360;
|
|
10
|
+
}
|
|
11
|
+
/** Map angle (0-360) to the nearest snap interval in minutes */
|
|
12
|
+
export function angleToMinute(deg) {
|
|
13
|
+
const norm = ((deg % 360) + 360) % 360;
|
|
14
|
+
const raw = 5 + (norm / 360) * (60 - 5);
|
|
15
|
+
return SNAP_INTERVALS.reduce((prev, curr) => Math.abs(curr - raw) < Math.abs(prev - raw) ? curr : prev);
|
|
16
|
+
}
|
|
17
|
+
/** Map a 0-1 progress value to a point on a semi-elliptical sundial arc */
|
|
18
|
+
export function pointOnSundialArc(t, cx, cy, arcWidth, arcHeight) {
|
|
19
|
+
const angle = Math.PI * (1 - t); // PI to 0
|
|
20
|
+
const x = cx + (arcWidth / 2) * Math.cos(angle);
|
|
21
|
+
const y = cy - arcHeight * Math.sin(angle);
|
|
22
|
+
return { x, y };
|
|
23
|
+
}
|
|
24
|
+
/** Build an SVG arc path for the timer ring */
|
|
25
|
+
export function buildArcPath(cx, cy, radius, angleDeg) {
|
|
26
|
+
if (angleDeg <= 0)
|
|
27
|
+
return "";
|
|
28
|
+
const start = polarToXY(cx, cy, radius, 0);
|
|
29
|
+
const end = polarToXY(cx, cy, radius, angleDeg);
|
|
30
|
+
const largeArc = angleDeg > 180 ? 1 : 0;
|
|
31
|
+
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArc} 1 ${end.x} ${end.y}`;
|
|
32
|
+
}
|
|
33
|
+
/** Build a full circle SVG path */
|
|
34
|
+
export function buildFullCirclePath(cx, cy, radius) {
|
|
35
|
+
const start = polarToXY(cx, cy, radius, 0);
|
|
36
|
+
const end = polarToXY(cx, cy, radius, 359.99);
|
|
37
|
+
return `M ${start.x} ${start.y} A ${radius} ${radius} 0 1 1 ${end.x} ${end.y}`;
|
|
38
|
+
}
|
|
39
|
+
/** Build the sundial arc SVG path from 40 segments */
|
|
40
|
+
export function buildSundialArcPath(cx, cy, arcWidth, arcHeight, segments = 40) {
|
|
41
|
+
const points = [];
|
|
42
|
+
for (let i = 0; i <= segments; i++) {
|
|
43
|
+
const t = i / segments;
|
|
44
|
+
const p = pointOnSundialArc(t, cx, cy, arcWidth, arcHeight);
|
|
45
|
+
points.push(`${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`);
|
|
46
|
+
}
|
|
47
|
+
return points.join(" ");
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SoundId } from "../types/index.js";
|
|
2
|
+
export interface AmbientPlayer {
|
|
3
|
+
start(): void;
|
|
4
|
+
stop(): void;
|
|
5
|
+
setVolume(v: number): void;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Create a procedural ambient sound using Web Audio API.
|
|
9
|
+
* All sounds are generated in real-time — no audio files needed.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createAmbientPlayer(audioContext: AudioContext, soundId: SoundId): AmbientPlayer;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a procedural ambient sound using Web Audio API.
|
|
3
|
+
* All sounds are generated in real-time — no audio files needed.
|
|
4
|
+
*/
|
|
5
|
+
export function createAmbientPlayer(audioContext, soundId) {
|
|
6
|
+
const masterGain = audioContext.createGain();
|
|
7
|
+
masterGain.gain.value = 0;
|
|
8
|
+
masterGain.connect(audioContext.destination);
|
|
9
|
+
const nodes = [];
|
|
10
|
+
let started = false;
|
|
11
|
+
function buildRain() {
|
|
12
|
+
// Brown noise filtered to sound like rain
|
|
13
|
+
const bufferSize = audioContext.sampleRate * 2;
|
|
14
|
+
const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
|
|
15
|
+
const data = buffer.getChannelData(0);
|
|
16
|
+
let lastOut = 0;
|
|
17
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
18
|
+
const white = Math.random() * 2 - 1;
|
|
19
|
+
lastOut = (lastOut + 0.02 * white) / 1.02;
|
|
20
|
+
data[i] = lastOut * 3.5;
|
|
21
|
+
}
|
|
22
|
+
const source = audioContext.createBufferSource();
|
|
23
|
+
source.buffer = buffer;
|
|
24
|
+
source.loop = true;
|
|
25
|
+
// Bandpass to shape into rain character
|
|
26
|
+
const bp = audioContext.createBiquadFilter();
|
|
27
|
+
bp.type = "bandpass";
|
|
28
|
+
bp.frequency.value = 2500;
|
|
29
|
+
bp.Q.value = 0.5;
|
|
30
|
+
// Highpass to remove rumble
|
|
31
|
+
const hp = audioContext.createBiquadFilter();
|
|
32
|
+
hp.type = "highpass";
|
|
33
|
+
hp.frequency.value = 400;
|
|
34
|
+
source.connect(bp);
|
|
35
|
+
bp.connect(hp);
|
|
36
|
+
hp.connect(masterGain);
|
|
37
|
+
nodes.push(source);
|
|
38
|
+
return source;
|
|
39
|
+
}
|
|
40
|
+
function buildOcean() {
|
|
41
|
+
// Noise shaped with slow LFO amplitude modulation for wave surges
|
|
42
|
+
const bufferSize = audioContext.sampleRate * 4;
|
|
43
|
+
const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
|
|
44
|
+
const data = buffer.getChannelData(0);
|
|
45
|
+
let lastOut = 0;
|
|
46
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
47
|
+
const white = Math.random() * 2 - 1;
|
|
48
|
+
lastOut = (lastOut + 0.04 * white) / 1.04;
|
|
49
|
+
data[i] = lastOut * 3;
|
|
50
|
+
}
|
|
51
|
+
const source = audioContext.createBufferSource();
|
|
52
|
+
source.buffer = buffer;
|
|
53
|
+
source.loop = true;
|
|
54
|
+
// Low-pass for deep ocean rumble
|
|
55
|
+
const lp = audioContext.createBiquadFilter();
|
|
56
|
+
lp.type = "lowpass";
|
|
57
|
+
lp.frequency.value = 800;
|
|
58
|
+
lp.Q.value = 0.3;
|
|
59
|
+
// LFO for wave-like amplitude modulation
|
|
60
|
+
const lfo = audioContext.createOscillator();
|
|
61
|
+
const lfoGain = audioContext.createGain();
|
|
62
|
+
lfo.frequency.value = 0.12; // ~8 second wave cycle
|
|
63
|
+
lfo.type = "sine";
|
|
64
|
+
lfoGain.gain.value = 0.4;
|
|
65
|
+
const modGain = audioContext.createGain();
|
|
66
|
+
modGain.gain.value = 0.6;
|
|
67
|
+
lfo.connect(lfoGain);
|
|
68
|
+
lfoGain.connect(modGain.gain);
|
|
69
|
+
source.connect(lp);
|
|
70
|
+
lp.connect(modGain);
|
|
71
|
+
modGain.connect(masterGain);
|
|
72
|
+
lfo.start();
|
|
73
|
+
nodes.push(source, lfo);
|
|
74
|
+
return source;
|
|
75
|
+
}
|
|
76
|
+
function buildForest() {
|
|
77
|
+
// Gentle ambient hiss (wind through leaves) + subtle tonal hum
|
|
78
|
+
const bufferSize = audioContext.sampleRate * 3;
|
|
79
|
+
const buffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
|
|
80
|
+
const data = buffer.getChannelData(0);
|
|
81
|
+
for (let i = 0; i < bufferSize; i++) {
|
|
82
|
+
data[i] = (Math.random() * 2 - 1) * 0.3;
|
|
83
|
+
}
|
|
84
|
+
const source = audioContext.createBufferSource();
|
|
85
|
+
source.buffer = buffer;
|
|
86
|
+
source.loop = true;
|
|
87
|
+
// Very narrow bandpass for wind character
|
|
88
|
+
const bp = audioContext.createBiquadFilter();
|
|
89
|
+
bp.type = "bandpass";
|
|
90
|
+
bp.frequency.value = 1200;
|
|
91
|
+
bp.Q.value = 0.8;
|
|
92
|
+
// Slow modulation for rustling
|
|
93
|
+
const lfo = audioContext.createOscillator();
|
|
94
|
+
const lfoGain = audioContext.createGain();
|
|
95
|
+
lfo.frequency.value = 0.3;
|
|
96
|
+
lfo.type = "sine";
|
|
97
|
+
lfoGain.gain.value = 0.3;
|
|
98
|
+
const modGain = audioContext.createGain();
|
|
99
|
+
modGain.gain.value = 0.7;
|
|
100
|
+
lfo.connect(lfoGain);
|
|
101
|
+
lfoGain.connect(modGain.gain);
|
|
102
|
+
source.connect(bp);
|
|
103
|
+
bp.connect(modGain);
|
|
104
|
+
modGain.connect(masterGain);
|
|
105
|
+
lfo.start();
|
|
106
|
+
nodes.push(source, lfo);
|
|
107
|
+
return source;
|
|
108
|
+
}
|
|
109
|
+
function buildBowl() {
|
|
110
|
+
// Continuous singing bowl drone: layered sine tones with slow beating
|
|
111
|
+
const fundamentals = [174.6, 220, 277.2]; // F3, A3, C#4 (a shimmering chord)
|
|
112
|
+
const sources = [];
|
|
113
|
+
fundamentals.forEach((freq, i) => {
|
|
114
|
+
// Main tone
|
|
115
|
+
const osc = audioContext.createOscillator();
|
|
116
|
+
osc.frequency.value = freq;
|
|
117
|
+
osc.type = "sine";
|
|
118
|
+
// Slightly detuned copy for natural beating
|
|
119
|
+
const osc2 = audioContext.createOscillator();
|
|
120
|
+
osc2.frequency.value = freq + 0.5 + i * 0.3;
|
|
121
|
+
osc2.type = "sine";
|
|
122
|
+
const g = audioContext.createGain();
|
|
123
|
+
g.gain.value = 0.08 / (i + 1);
|
|
124
|
+
osc.connect(g);
|
|
125
|
+
osc2.connect(g);
|
|
126
|
+
g.connect(masterGain);
|
|
127
|
+
sources.push(osc, osc2);
|
|
128
|
+
nodes.push(osc, osc2);
|
|
129
|
+
});
|
|
130
|
+
return sources;
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
start() {
|
|
134
|
+
if (started)
|
|
135
|
+
return;
|
|
136
|
+
started = true;
|
|
137
|
+
if (soundId === "rain") {
|
|
138
|
+
buildRain().start();
|
|
139
|
+
}
|
|
140
|
+
else if (soundId === "ocean") {
|
|
141
|
+
buildOcean().start();
|
|
142
|
+
}
|
|
143
|
+
else if (soundId === "forest") {
|
|
144
|
+
buildForest().start();
|
|
145
|
+
}
|
|
146
|
+
else if (soundId === "bowl") {
|
|
147
|
+
const sources = buildBowl();
|
|
148
|
+
sources.forEach((s) => s.start());
|
|
149
|
+
}
|
|
150
|
+
// Fade in
|
|
151
|
+
masterGain.gain.setValueAtTime(0, audioContext.currentTime);
|
|
152
|
+
masterGain.gain.linearRampToValueAtTime(0.6, audioContext.currentTime + 1.5);
|
|
153
|
+
},
|
|
154
|
+
stop() {
|
|
155
|
+
if (!started)
|
|
156
|
+
return;
|
|
157
|
+
const now = audioContext.currentTime;
|
|
158
|
+
masterGain.gain.linearRampToValueAtTime(0, now + 1.0);
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
nodes.forEach((n) => {
|
|
161
|
+
try {
|
|
162
|
+
if (n instanceof AudioScheduledSourceNode)
|
|
163
|
+
n.stop();
|
|
164
|
+
}
|
|
165
|
+
catch { /* already stopped */ }
|
|
166
|
+
});
|
|
167
|
+
started = false;
|
|
168
|
+
}, 1200);
|
|
169
|
+
},
|
|
170
|
+
setVolume(v) {
|
|
171
|
+
masterGain.gain.linearRampToValueAtTime(Math.max(0, Math.min(1, v)), audioContext.currentTime + 0.1);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Play a singing bowl strike — layered harmonics with long decay.
|
|
3
|
+
* Much warmer and more resonant than the previous sine-wave beeps.
|
|
4
|
+
*/
|
|
5
|
+
export function playChime(audioContext) {
|
|
6
|
+
const now = audioContext.currentTime;
|
|
7
|
+
// Singing bowl harmonics: fundamental + partials at non-integer ratios
|
|
8
|
+
// This creates the characteristic metallic shimmer
|
|
9
|
+
const harmonics = [
|
|
10
|
+
{ freq: 220, gain: 0.25, decay: 4.0 }, // fundamental A3
|
|
11
|
+
{ freq: 440, gain: 0.15, decay: 3.5 }, // octave
|
|
12
|
+
{ freq: 556, gain: 0.08, decay: 3.0 }, // non-integer partial (bowl character)
|
|
13
|
+
{ freq: 660, gain: 0.06, decay: 2.5 }, // fifth
|
|
14
|
+
{ freq: 880, gain: 0.04, decay: 2.0 }, // double octave
|
|
15
|
+
{ freq: 1120, gain: 0.02, decay: 1.5 }, // high shimmer
|
|
16
|
+
];
|
|
17
|
+
harmonics.forEach(({ freq, gain: vol, decay }) => {
|
|
18
|
+
const osc = audioContext.createOscillator();
|
|
19
|
+
const gainNode = audioContext.createGain();
|
|
20
|
+
osc.connect(gainNode);
|
|
21
|
+
gainNode.connect(audioContext.destination);
|
|
22
|
+
osc.frequency.value = freq;
|
|
23
|
+
osc.type = "sine";
|
|
24
|
+
// Attack: quick rise then long exponential decay
|
|
25
|
+
gainNode.gain.setValueAtTime(0.001, now);
|
|
26
|
+
gainNode.gain.linearRampToValueAtTime(vol, now + 0.02);
|
|
27
|
+
gainNode.gain.exponentialRampToValueAtTime(0.001, now + decay);
|
|
28
|
+
osc.start(now);
|
|
29
|
+
osc.stop(now + decay + 0.1);
|
|
30
|
+
});
|
|
31
|
+
// Add a second softer strike 1.5s later for depth
|
|
32
|
+
const delay = 1.5;
|
|
33
|
+
[220, 440, 556].forEach((freq, i) => {
|
|
34
|
+
const osc = audioContext.createOscillator();
|
|
35
|
+
const gainNode = audioContext.createGain();
|
|
36
|
+
osc.connect(gainNode);
|
|
37
|
+
gainNode.connect(audioContext.destination);
|
|
38
|
+
osc.frequency.value = freq;
|
|
39
|
+
osc.type = "sine";
|
|
40
|
+
const vol = [0.12, 0.07, 0.04][i];
|
|
41
|
+
gainNode.gain.setValueAtTime(0.001, now + delay);
|
|
42
|
+
gainNode.gain.linearRampToValueAtTime(vol, now + delay + 0.02);
|
|
43
|
+
gainNode.gain.exponentialRampToValueAtTime(0.001, now + delay + 3.5);
|
|
44
|
+
osc.start(now + delay);
|
|
45
|
+
osc.stop(now + delay + 3.6);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import SunCalc from "suncalc";
|
|
2
|
+
export const DEFAULT_COORDS = { lat: 51.5074, lng: -0.1278 };
|
|
3
|
+
export function calculateCelestial(coords, now) {
|
|
4
|
+
const date = now ?? new Date();
|
|
5
|
+
const times = SunCalc.getTimes(date, coords.lat, coords.lng);
|
|
6
|
+
const sunPos = SunCalc.getPosition(date, coords.lat, coords.lng);
|
|
7
|
+
const moonPos = SunCalc.getMoonPosition(date, coords.lat, coords.lng);
|
|
8
|
+
const moonIllum = SunCalc.getMoonIllumination(date);
|
|
9
|
+
const sunrise = times.sunrise;
|
|
10
|
+
const sunset = times.sunset;
|
|
11
|
+
const isDaytime = date >= sunrise && date <= sunset;
|
|
12
|
+
const dayLength = sunset.getTime() - sunrise.getTime();
|
|
13
|
+
const sunArcProgress = dayLength > 0
|
|
14
|
+
? Math.max(0, Math.min(1, (date.getTime() - sunrise.getTime()) / dayLength))
|
|
15
|
+
: 0.5;
|
|
16
|
+
// Moon arc: normalize azimuth from -PI..PI to 0..1
|
|
17
|
+
const moonArcProgress = (moonPos.azimuth + Math.PI) / (2 * Math.PI);
|
|
18
|
+
const locationGranted = coords.lat !== DEFAULT_COORDS.lat || coords.lng !== DEFAULT_COORDS.lng;
|
|
19
|
+
return {
|
|
20
|
+
sunrise,
|
|
21
|
+
sunset,
|
|
22
|
+
sunPosition: sunPos,
|
|
23
|
+
moonPosition: moonPos,
|
|
24
|
+
moonPhase: moonIllum.phase,
|
|
25
|
+
isDaytime,
|
|
26
|
+
sunArcProgress,
|
|
27
|
+
moonArcProgress,
|
|
28
|
+
locationGranted,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export const constellations = [
|
|
2
|
+
{
|
|
3
|
+
name: "Aquarius",
|
|
4
|
+
points: [[170, 100], [140, 80], [110, 100], [80, 80], [50, 100], [140, 120], [110, 140], [80, 120]],
|
|
5
|
+
paths: ["M 170 100 L 140 80 L 110 100 L 80 80 L 50 100", "M 140 80 L 140 120 L 110 140 L 80 120 L 80 80"],
|
|
6
|
+
dateRange: [120, 218],
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: "Pisces",
|
|
10
|
+
points: [[180, 60], [140, 90], [100, 110], [60, 130], [20, 160], [60, 80], [40, 40], [80, 40]],
|
|
11
|
+
paths: ["M 180 60 L 140 90 L 100 110 L 60 130 L 20 160", "M 100 110 L 60 80 L 40 40 L 80 40 L 60 80"],
|
|
12
|
+
dateRange: [219, 320],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "Aries",
|
|
16
|
+
points: [[170, 60], [130, 50], [100, 80], [80, 110]],
|
|
17
|
+
paths: ["M 170 60 L 130 50 L 100 80 L 80 110"],
|
|
18
|
+
dateRange: [321, 419],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "Taurus",
|
|
22
|
+
points: [[180, 30], [140, 70], [110, 90], [115, 110], [90, 120], [50, 130], [30, 160]],
|
|
23
|
+
paths: ["M 180 30 L 140 70 L 110 90", "M 110 90 L 115 110 L 90 120 L 50 130 L 30 160"],
|
|
24
|
+
dateRange: [420, 520],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "Gemini",
|
|
28
|
+
points: [[150, 40], [170, 70], [130, 100], [100, 120], [70, 140], [50, 170], [130, 50], [110, 80], [80, 100]],
|
|
29
|
+
paths: ["M 150 40 L 170 70 L 130 100 L 100 120 L 70 140 L 50 170", "M 130 50 L 110 80 L 80 100"],
|
|
30
|
+
dateRange: [521, 620],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "Cancer",
|
|
34
|
+
points: [[100, 50], [100, 90], [70, 120], [130, 120]],
|
|
35
|
+
paths: ["M 100 50 L 100 90 L 70 120", "M 100 90 L 130 120"],
|
|
36
|
+
dateRange: [621, 722],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "Leo",
|
|
40
|
+
points: [[160, 140], [120, 150], [80, 140], [60, 110], [70, 70], [100, 60], [120, 80], [150, 80]],
|
|
41
|
+
paths: ["M 160 140 L 120 150 L 80 140 L 60 110 L 70 70 L 100 60 L 120 80 L 100 60", "M 120 80 L 150 80"],
|
|
42
|
+
dateRange: [723, 822],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "Virgo",
|
|
46
|
+
points: [[170, 60], [140, 90], [110, 100], [100, 130], [70, 140], [50, 110], [40, 80], [130, 150]],
|
|
47
|
+
paths: ["M 170 60 L 140 90 L 110 100 L 100 130 L 70 140 L 50 110 L 40 80", "M 100 130 L 130 150"],
|
|
48
|
+
dateRange: [823, 922],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "Libra",
|
|
52
|
+
points: [[100, 60], [130, 100], [100, 140], [70, 100], [40, 120]],
|
|
53
|
+
paths: ["M 100 60 L 130 100 L 100 140 L 70 100 L 130 100", "M 70 100 L 40 120"],
|
|
54
|
+
dateRange: [923, 1022],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "Scorpio",
|
|
58
|
+
points: [[180, 50], [140, 60], [110, 80], [100, 120], [110, 150], [140, 160], [170, 150]],
|
|
59
|
+
paths: ["M 180 50 L 140 60 L 110 80 L 100 120 L 110 150 L 140 160 L 170 150"],
|
|
60
|
+
dateRange: [1023, 1121],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: "Sagittarius",
|
|
64
|
+
points: [[160, 80], [130, 70], [100, 80], [100, 120], [130, 130], [160, 120], [180, 60], [180, 140]],
|
|
65
|
+
paths: ["M 160 80 L 130 70 L 100 80 L 100 120 L 130 130 L 160 120 L 160 80", "M 160 80 L 180 60", "M 160 120 L 180 140"],
|
|
66
|
+
dateRange: [1122, 1221],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: "Capricorn",
|
|
70
|
+
points: [[180, 80], [120, 60], [60, 80], [40, 120], [80, 160], [140, 140]],
|
|
71
|
+
paths: ["M 180 80 L 120 60 L 60 80 L 40 120 L 80 160 L 140 140 L 180 80"],
|
|
72
|
+
dateRange: [1222, 119],
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
export function getCurrentConstellation(now) {
|
|
76
|
+
const date = now ?? new Date();
|
|
77
|
+
const mmdd = (date.getMonth() + 1) * 100 + date.getDate();
|
|
78
|
+
for (const c of constellations) {
|
|
79
|
+
if (c.dateRange[0] <= c.dateRange[1]) {
|
|
80
|
+
if (mmdd >= c.dateRange[0] && mmdd <= c.dateRange[1])
|
|
81
|
+
return c;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Wraps around year (Capricorn)
|
|
85
|
+
if (mmdd >= c.dateRange[0] || mmdd <= c.dateRange[1])
|
|
86
|
+
return c;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return constellations[0]; // fallback
|
|
90
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./types/index.js";
|
|
2
|
+
export * from "./timer/timer.js";
|
|
3
|
+
export * from "./celestial/celestial.js";
|
|
4
|
+
export * from "./constellation/constellations.js";
|
|
5
|
+
export * from "./audio/chime.js";
|
|
6
|
+
export * from "./audio/ambient.js";
|
|
7
|
+
export * from "./arc/arc.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from "./types/index.js";
|
|
2
|
+
export * from "./timer/timer.js";
|
|
3
|
+
export * from "./celestial/celestial.js";
|
|
4
|
+
export * from "./constellation/constellations.js";
|
|
5
|
+
export * from "./audio/chime.js";
|
|
6
|
+
export * from "./audio/ambient.js";
|
|
7
|
+
export * from "./arc/arc.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SessionState, SoundId, BreathCycleConfig } from "../types/index.js";
|
|
2
|
+
export declare const SNAP_INTERVALS: readonly [5, 10, 15, 20, 25, 30, 45, 60];
|
|
3
|
+
export declare const BREATHE_CYCLE: BreathCycleConfig;
|
|
4
|
+
export declare function createInitialSession(): SessionState;
|
|
5
|
+
export declare function clampDuration(minutes: number): number;
|
|
6
|
+
export declare function snapToInterval(minutes: number): number;
|
|
7
|
+
/**
|
|
8
|
+
* Compute elapsed from wall-clock time. Call on every tick AND
|
|
9
|
+
* on visibilitychange to catch up after background throttling.
|
|
10
|
+
*/
|
|
11
|
+
export declare function syncSession(session: SessionState, now: number): SessionState;
|
|
12
|
+
export declare function isSessionComplete(prev: SessionState, next: SessionState): boolean;
|
|
13
|
+
export declare function toggleSession(session: SessionState, now: number): SessionState;
|
|
14
|
+
export declare function resetSession(session: SessionState): SessionState;
|
|
15
|
+
export declare function setDuration(session: SessionState, minutes: number): SessionState;
|
|
16
|
+
export declare function setSound(session: SessionState, sound: SoundId | null): SessionState;
|
|
17
|
+
export declare function getRemainingSeconds(session: SessionState): number;
|
|
18
|
+
export declare function formatTime(remainingSeconds: number): {
|
|
19
|
+
minutes: string;
|
|
20
|
+
seconds: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const SNAP_INTERVALS = [5, 10, 15, 20, 25, 30, 45, 60];
|
|
2
|
+
export const BREATHE_CYCLE = {
|
|
3
|
+
inhale: 4,
|
|
4
|
+
hold: 7,
|
|
5
|
+
exhale: 8,
|
|
6
|
+
};
|
|
7
|
+
export function createInitialSession() {
|
|
8
|
+
return {
|
|
9
|
+
status: "idle",
|
|
10
|
+
duration: 10,
|
|
11
|
+
elapsed: 0,
|
|
12
|
+
startedAt: null,
|
|
13
|
+
elapsedBeforePause: 0,
|
|
14
|
+
selectedSound: null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function clampDuration(minutes) {
|
|
18
|
+
return Math.max(5, Math.min(60, minutes));
|
|
19
|
+
}
|
|
20
|
+
export function snapToInterval(minutes) {
|
|
21
|
+
return SNAP_INTERVALS.reduce((prev, curr) => Math.abs(curr - minutes) < Math.abs(prev - minutes) ? curr : prev);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Compute elapsed from wall-clock time. Call on every tick AND
|
|
25
|
+
* on visibilitychange to catch up after background throttling.
|
|
26
|
+
*/
|
|
27
|
+
export function syncSession(session, now) {
|
|
28
|
+
if (session.status !== "active" || session.startedAt === null)
|
|
29
|
+
return session;
|
|
30
|
+
const totalSeconds = session.duration * 60;
|
|
31
|
+
const wallElapsed = Math.floor((now - session.startedAt) / 1000);
|
|
32
|
+
const newElapsed = Math.min(session.elapsedBeforePause + wallElapsed, totalSeconds);
|
|
33
|
+
if (newElapsed >= totalSeconds) {
|
|
34
|
+
return {
|
|
35
|
+
...session,
|
|
36
|
+
status: "completed",
|
|
37
|
+
elapsed: totalSeconds,
|
|
38
|
+
startedAt: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
...session,
|
|
43
|
+
elapsed: newElapsed,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function isSessionComplete(prev, next) {
|
|
47
|
+
return prev.status === "active" && next.status === "completed";
|
|
48
|
+
}
|
|
49
|
+
export function toggleSession(session, now) {
|
|
50
|
+
if (session.status === "active") {
|
|
51
|
+
// Pausing
|
|
52
|
+
return {
|
|
53
|
+
...session,
|
|
54
|
+
status: "paused",
|
|
55
|
+
elapsedBeforePause: session.elapsed,
|
|
56
|
+
startedAt: null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Starting or resuming (from idle, paused, or completed)
|
|
61
|
+
const isRestart = session.status === "completed" || session.status === "idle";
|
|
62
|
+
return {
|
|
63
|
+
...session,
|
|
64
|
+
status: "active",
|
|
65
|
+
elapsed: isRestart ? 0 : session.elapsed,
|
|
66
|
+
elapsedBeforePause: isRestart ? 0 : session.elapsedBeforePause,
|
|
67
|
+
startedAt: now,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function resetSession(session) {
|
|
72
|
+
return {
|
|
73
|
+
...session,
|
|
74
|
+
status: "idle",
|
|
75
|
+
elapsed: 0,
|
|
76
|
+
elapsedBeforePause: 0,
|
|
77
|
+
startedAt: null,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function setDuration(session, minutes) {
|
|
81
|
+
return {
|
|
82
|
+
...session,
|
|
83
|
+
duration: clampDuration(minutes),
|
|
84
|
+
elapsed: 0,
|
|
85
|
+
elapsedBeforePause: 0,
|
|
86
|
+
startedAt: null,
|
|
87
|
+
status: "idle",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export function setSound(session, sound) {
|
|
91
|
+
return {
|
|
92
|
+
...session,
|
|
93
|
+
selectedSound: sound,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function getRemainingSeconds(session) {
|
|
97
|
+
return session.duration * 60 - session.elapsed;
|
|
98
|
+
}
|
|
99
|
+
export function formatTime(remainingSeconds) {
|
|
100
|
+
const mins = Math.floor(remainingSeconds / 60);
|
|
101
|
+
const secs = remainingSeconds % 60;
|
|
102
|
+
return {
|
|
103
|
+
minutes: String(mins).padStart(2, "0"),
|
|
104
|
+
seconds: String(secs).padStart(2, "0"),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type SoundId = "rain" | "forest" | "ocean" | "bowl";
|
|
2
|
+
export type BreathPhase = "inhale" | "hold" | "exhale";
|
|
3
|
+
export interface BreathCycleConfig {
|
|
4
|
+
inhale: number;
|
|
5
|
+
hold: number;
|
|
6
|
+
exhale: number;
|
|
7
|
+
}
|
|
8
|
+
export type SessionStatus = "idle" | "active" | "paused" | "completed";
|
|
9
|
+
export interface SessionState {
|
|
10
|
+
status: SessionStatus;
|
|
11
|
+
/** Duration in minutes */
|
|
12
|
+
duration: number;
|
|
13
|
+
/** Elapsed time in seconds */
|
|
14
|
+
elapsed: number;
|
|
15
|
+
/** Wall-clock timestamp (ms) when the session was started/resumed */
|
|
16
|
+
startedAt: number | null;
|
|
17
|
+
/** Elapsed seconds accumulated before the most recent pause */
|
|
18
|
+
elapsedBeforePause: number;
|
|
19
|
+
selectedSound: SoundId | null;
|
|
20
|
+
}
|
|
21
|
+
export interface Coordinates {
|
|
22
|
+
lat: number;
|
|
23
|
+
lng: number;
|
|
24
|
+
}
|
|
25
|
+
export interface CelestialData {
|
|
26
|
+
sunrise: Date | null;
|
|
27
|
+
sunset: Date | null;
|
|
28
|
+
sunPosition: {
|
|
29
|
+
altitude: number;
|
|
30
|
+
azimuth: number;
|
|
31
|
+
} | null;
|
|
32
|
+
moonPosition: {
|
|
33
|
+
altitude: number;
|
|
34
|
+
azimuth: number;
|
|
35
|
+
} | null;
|
|
36
|
+
/** 0-1 representing the lunar phase */
|
|
37
|
+
moonPhase: number;
|
|
38
|
+
isDaytime: boolean;
|
|
39
|
+
/** Sun progress along the arc: 0 = sunrise, 1 = sunset */
|
|
40
|
+
sunArcProgress: number;
|
|
41
|
+
/** Moon progress along the arc: 0-1 based on azimuth */
|
|
42
|
+
moonArcProgress: number;
|
|
43
|
+
locationGranted: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface Constellation {
|
|
46
|
+
name: string;
|
|
47
|
+
points: [number, number][];
|
|
48
|
+
paths: string[];
|
|
49
|
+
/** [startMMDD, endMMDD] */
|
|
50
|
+
dateRange: [number, number];
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elmahdistudio/zenith-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": ["dist"],
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc --project tsconfig.json",
|
|
16
|
+
"test": "vitest run"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"suncalc": "^1.9.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/suncalc": "^1.9.2",
|
|
23
|
+
"typescript": "^5.8.3"
|
|
24
|
+
}
|
|
25
|
+
}
|