natural-time-js 1.0.5 → 1.0.6
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/constants.js +52 -0
- package/context.js +240 -126
- package/index.js +83 -72
- package/package.json +1 -1
- package/utils/validators.js +136 -0
package/constants.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module constants
|
|
3
|
+
* @description Collection of constants used for natural time and astronomical calculations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Number of milliseconds in a day
|
|
8
|
+
* @constant {number}
|
|
9
|
+
*/
|
|
10
|
+
export const MILLISECONDS_PER_DAY = 86400000; // 24 * 60 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* End date of the artificial time era (December 21, 2012 12:00 UTC)
|
|
14
|
+
* @constant {number}
|
|
15
|
+
*/
|
|
16
|
+
export const END_OF_ARTIFICIAL_TIME = Date.UTC(2012, 11, 21, 12, 0, 0);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hemisphere identifiers for geographical calculations
|
|
20
|
+
* @constant
|
|
21
|
+
* @enum {string}
|
|
22
|
+
* @readonly
|
|
23
|
+
*/
|
|
24
|
+
export const HEMISPHERES = {
|
|
25
|
+
NORTH: 'NORTH',
|
|
26
|
+
SOUTH: 'SOUTH'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Day numbers marking the start and end of summer season
|
|
31
|
+
* Day 1 represents January 1st
|
|
32
|
+
* @constant
|
|
33
|
+
* @enum {number}
|
|
34
|
+
* @readonly
|
|
35
|
+
*/
|
|
36
|
+
export const SEASONS = {
|
|
37
|
+
/** Day 91 (typically April 1st) */
|
|
38
|
+
SUMMER_START_DAY: 91,
|
|
39
|
+
/** Day 273 (typically September 30th) */
|
|
40
|
+
SUMMER_END_DAY: 273
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Solar altitude thresholds in degrees for different daylight conditions
|
|
45
|
+
* @constant
|
|
46
|
+
* @enum {number}
|
|
47
|
+
* @readonly
|
|
48
|
+
*/
|
|
49
|
+
export const ANGLES = {
|
|
50
|
+
NIGHT_ALTITUDE: -12,
|
|
51
|
+
GOLDEN_HOUR_ALTITUDE: 6
|
|
52
|
+
}
|
package/context.js
CHANGED
|
@@ -1,171 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module context
|
|
3
|
+
* @description Provides astronomical calculations and events for natural time, including sun and moon positions
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import { NaturalDate } from './index.js';
|
|
2
7
|
import { Body, Observer, SearchHourAngle, SearchRiseSet, SearchAltitude, MoonPhase, Equator, Horizon, Seasons } from 'astronomy-engine';
|
|
8
|
+
import { HEMISPHERES, SEASONS, ANGLES } from './constants.js';
|
|
9
|
+
import {
|
|
10
|
+
isValidLatitude,
|
|
11
|
+
isValidLongitude,
|
|
12
|
+
isValidNaturalDate,
|
|
13
|
+
throwValidationError
|
|
14
|
+
} from './utils/validators.js';
|
|
3
15
|
|
|
4
|
-
//
|
|
5
|
-
const
|
|
16
|
+
// Move cache to its own module for better separation of concerns
|
|
17
|
+
const astroCache = new Map();
|
|
6
18
|
|
|
7
19
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* @
|
|
12
|
-
* @param {Number} latitude
|
|
13
|
-
* @returns Object { altitude, highestAltitude }
|
|
20
|
+
* Determines the hemisphere based on latitude
|
|
21
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
22
|
+
* @returns {string} HEMISPHERES.NORTH or HEMISPHERES.SOUTH
|
|
23
|
+
* @private
|
|
14
24
|
*/
|
|
15
|
-
|
|
25
|
+
const getHemisphere = (latitude) => latitude >= 0 ? HEMISPHERES.NORTH : HEMISPHERES.SOUTH;
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Determines if the given day is in summer season for the specified latitude
|
|
29
|
+
* @param {number} dayOfYear - Day of the year (1-366)
|
|
30
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
31
|
+
* @returns {boolean} True if the day is in summer season
|
|
32
|
+
* @private
|
|
33
|
+
*/
|
|
34
|
+
const isSummerSeason = (dayOfYear, latitude) => {
|
|
35
|
+
const hemisphere = getHemisphere(latitude);
|
|
36
|
+
return hemisphere === HEMISPHERES.NORTH
|
|
37
|
+
? (dayOfYear >= SEASONS.SUMMER_START_DAY && dayOfYear <= SEASONS.SUMMER_END_DAY)
|
|
38
|
+
: (dayOfYear <= SEASONS.SUMMER_START_DAY || dayOfYear >= SEASONS.SUMMER_END_DAY);
|
|
39
|
+
}
|
|
19
40
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Creates an astronomical observer at the specified coordinates
|
|
43
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
44
|
+
* @param {number} longitude - Longitude in degrees (-180 to 180)
|
|
45
|
+
* @returns {Observer} Astronomy-engine observer instance
|
|
46
|
+
* @private
|
|
47
|
+
*/
|
|
48
|
+
const createObserver = (latitude, longitude) => new Observer(latitude, longitude, 0);
|
|
23
49
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Calculates sun altitude data for a given natural date and latitude
|
|
52
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
53
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
54
|
+
* @returns {{
|
|
55
|
+
* altitude: number,
|
|
56
|
+
* highestAltitude: number
|
|
57
|
+
* }} Sun altitude data
|
|
58
|
+
* @throws {Error} If inputs are invalid or calculation fails
|
|
59
|
+
*/
|
|
60
|
+
export function NaturalSunAltitude(naturalDate, latitude) {
|
|
61
|
+
// Validate inputs
|
|
62
|
+
if (!isValidNaturalDate(naturalDate)) {
|
|
63
|
+
throwValidationError('naturalDate', naturalDate, 'NaturalDate instance');
|
|
64
|
+
}
|
|
65
|
+
if (!isValidLatitude(latitude)) {
|
|
66
|
+
throwValidationError('latitude', latitude, 'number between -90 and 90');
|
|
27
67
|
}
|
|
28
|
-
}
|
|
29
68
|
|
|
69
|
+
try {
|
|
70
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
71
|
+
const date = new Date(naturalDate.unixTime);
|
|
72
|
+
const sun = Equator(Body.Sun, date, observer, true, false);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
altitude: Math.max(Horizon(date, observer, sun.ra, sun.dec).altitude, 0),
|
|
76
|
+
highestAltitude: SearchHourAngle(Body.Sun, observer, 0, new Date(naturalDate.nadir)).hor.altitude
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
console.error('Error in NaturalSunAltitude:', error);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
30
83
|
|
|
31
84
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* @
|
|
36
|
-
*
|
|
37
|
-
*
|
|
85
|
+
* Calculates sun-related events for a given natural date and latitude
|
|
86
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
87
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
88
|
+
* @returns {{
|
|
89
|
+
* sunrise: number,
|
|
90
|
+
* sunset: number,
|
|
91
|
+
* nightStart: number,
|
|
92
|
+
* nightEnd: number,
|
|
93
|
+
* morningGoldenHour: number,
|
|
94
|
+
* eveningGoldenHour: number
|
|
95
|
+
* }} Sun events in natural degrees (0-360)
|
|
96
|
+
* @throws {Error} If inputs are invalid or calculation fails
|
|
38
97
|
*/
|
|
39
98
|
export function NaturalSunEvents(naturalDate, latitude) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
99
|
+
// Validate inputs
|
|
100
|
+
if (!isValidNaturalDate(naturalDate)) {
|
|
101
|
+
throwValidationError('naturalDate', naturalDate, 'NaturalDate instance');
|
|
102
|
+
}
|
|
103
|
+
if (!isValidLatitude(latitude)) {
|
|
104
|
+
throwValidationError('latitude', latitude, 'number between -90 and 90');
|
|
45
105
|
}
|
|
46
106
|
|
|
47
|
-
|
|
48
|
-
let nadir = new Date(naturalDate.nadir);
|
|
49
|
-
|
|
50
|
-
let isSummer =
|
|
51
|
-
latitude >= 0 && (naturalDate.dayOfYear >= 91 && naturalDate.dayOfYear <= 273) || // Northern hemisphere summer
|
|
52
|
-
latitude <= 0 && (naturalDate.dayOfYear <= 91 || naturalDate.dayOfYear >= 273); // Southern hemisphere summer
|
|
53
|
-
|
|
54
|
-
// SUNRISE & SUNSET
|
|
55
|
-
let sunrise = naturalDate.getTimeOfEvent(SearchRiseSet(Body.Sun, observer, +1, nadir, 1));
|
|
56
|
-
if(!sunrise) sunrise = isSummer ? 0 : 180;
|
|
57
|
-
let sunset = naturalDate.getTimeOfEvent(SearchRiseSet(Body.Sun, observer, -1, nadir, 1));
|
|
58
|
-
if(!sunset) sunset = isSummer ? 360 : 180;
|
|
59
|
-
|
|
60
|
-
// NIGHT (-12° altitude)
|
|
61
|
-
let nightStart = naturalDate.getTimeOfEvent(SearchAltitude(Body.Sun, observer, -1, nadir, 2, -12));
|
|
62
|
-
if(!nightStart) nightStart = isSummer ? 360 : 180;
|
|
63
|
-
let nightEnd = naturalDate.getTimeOfEvent(SearchAltitude(Body.Sun, observer, +1, nadir, 2, -12));
|
|
64
|
-
if(!nightEnd) nightEnd = isSummer ? 0 : 180;
|
|
65
|
-
|
|
66
|
-
// GOLDEN HOUR (+6° altitude)
|
|
67
|
-
let morningGoldenHour = naturalDate.getTimeOfEvent(SearchAltitude(Body.Sun, observer, +1, nadir, 2, +6));
|
|
68
|
-
if(!morningGoldenHour) morningGoldenHour = isSummer ? 0 : 180;
|
|
69
|
-
let eveningGoldenHour = naturalDate.getTimeOfEvent(SearchAltitude(Body.Sun, observer, -1, nadir, 2, +6));
|
|
70
|
-
if(!eveningGoldenHour) eveningGoldenHour = isSummer ? 360 : 180;
|
|
107
|
+
const cacheKey = `SUN_${naturalDate.toDateString()}_${latitude}_${naturalDate.longitude}`;
|
|
71
108
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
try {
|
|
110
|
+
if (astroCache.has(cacheKey)) {
|
|
111
|
+
const cachedResult = astroCache.get(cacheKey);
|
|
112
|
+
if (cachedResult && typeof cachedResult === 'object') {
|
|
113
|
+
return cachedResult;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
118
|
+
const nadir = new Date(naturalDate.nadir);
|
|
119
|
+
const isSummer = isSummerSeason(naturalDate.dayOfYear, latitude);
|
|
120
|
+
|
|
121
|
+
// Helper function to handle edge cases
|
|
122
|
+
const getEventTime = (searchResult, isSummerDefault) => {
|
|
123
|
+
if (!searchResult) {
|
|
124
|
+
return isSummer ? (isSummerDefault ? 360 : 0) : 180;
|
|
125
|
+
}
|
|
126
|
+
return naturalDate.getTimeOfEvent(searchResult);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const events = {
|
|
130
|
+
sunrise: getEventTime(SearchRiseSet(Body.Sun, observer, +1, nadir, 1), false),
|
|
131
|
+
sunset: getEventTime(SearchRiseSet(Body.Sun, observer, -1, nadir, 1), true),
|
|
132
|
+
nightStart: getEventTime(SearchAltitude(Body.Sun, observer, -1, nadir, 2, ANGLES.NIGHT_ALTITUDE), true),
|
|
133
|
+
nightEnd: getEventTime(SearchAltitude(Body.Sun, observer, +1, nadir, 2, ANGLES.NIGHT_ALTITUDE), false),
|
|
134
|
+
morningGoldenHour: getEventTime(SearchAltitude(Body.Sun, observer, +1, nadir, 2, ANGLES.GOLDEN_HOUR_ALTITUDE), false),
|
|
135
|
+
eveningGoldenHour: getEventTime(SearchAltitude(Body.Sun, observer, -1, nadir, 2, ANGLES.GOLDEN_HOUR_ALTITUDE), true)
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
astroCache.set(cacheKey, events);
|
|
139
|
+
return events;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('Error in NaturalSunEvents:', error);
|
|
142
|
+
throw error;
|
|
79
143
|
}
|
|
80
144
|
}
|
|
81
145
|
|
|
82
146
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @
|
|
87
|
-
*
|
|
88
|
-
*
|
|
147
|
+
* Calculates moon position and phase data for a given natural date and latitude
|
|
148
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
149
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
150
|
+
* @returns {{
|
|
151
|
+
* phase: number,
|
|
152
|
+
* altitude: number,
|
|
153
|
+
* highestAltitude: number
|
|
154
|
+
* }} Moon position and phase data
|
|
89
155
|
*/
|
|
90
156
|
export function NaturalMoonPosition(naturalDate, latitude) {
|
|
157
|
+
// Validate inputs
|
|
158
|
+
if (!isValidNaturalDate(naturalDate)) {
|
|
159
|
+
throwValidationError('naturalDate', naturalDate, 'NaturalDate instance');
|
|
160
|
+
}
|
|
161
|
+
if (!isValidLatitude(latitude)) {
|
|
162
|
+
throwValidationError('latitude', latitude, 'number between -90 and 90');
|
|
163
|
+
}
|
|
91
164
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
165
|
+
try {
|
|
166
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
167
|
+
const date = new Date(naturalDate.unixTime);
|
|
168
|
+
const moon = Equator(Body.Moon, date, observer, true, false);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
phase: MoonPhase(date),
|
|
172
|
+
altitude: Math.max(Horizon(date, observer, moon.ra, moon.dec).altitude, 0),
|
|
173
|
+
highestAltitude: SearchHourAngle(Body.Moon, observer, 0, new Date(naturalDate.nadir)).hor.altitude
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('Error in NaturalMoonPosition:', error);
|
|
177
|
+
return {
|
|
178
|
+
phase: 0,
|
|
179
|
+
altitude: 0,
|
|
180
|
+
highestAltitude: 0
|
|
181
|
+
}
|
|
107
182
|
}
|
|
108
183
|
}
|
|
109
184
|
|
|
110
185
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* @
|
|
115
|
-
*
|
|
116
|
-
*
|
|
186
|
+
* Calculates moon rise and set events for a given natural date and latitude
|
|
187
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
188
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
189
|
+
* @returns {{
|
|
190
|
+
* moonrise: number|null,
|
|
191
|
+
* moonset: number|null
|
|
192
|
+
* }} Moon events in natural degrees (0-360)
|
|
117
193
|
*/
|
|
118
194
|
export function NaturalMoonEvents(naturalDate, latitude) {
|
|
119
|
-
|
|
120
|
-
let cache_id = `MOON_${naturalDate.toDateString()}_${latitude}_${naturalDate.longitude}`;
|
|
195
|
+
const cacheKey = `MOON_${naturalDate.toDateString()}_${latitude}_${naturalDate.longitude}`;
|
|
121
196
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
197
|
+
try {
|
|
198
|
+
if (astroCache.has(cacheKey)) {
|
|
199
|
+
return astroCache.get(cacheKey);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
203
|
+
const nadir = new Date(naturalDate.nadir);
|
|
125
204
|
|
|
126
|
-
|
|
205
|
+
const moonrise = SearchRiseSet(Body.Moon, observer, +1, nadir, 1);
|
|
206
|
+
const moonset = SearchRiseSet(Body.Moon, observer, -1, nadir, 1);
|
|
127
207
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
208
|
+
const events = {
|
|
209
|
+
moonrise: naturalDate.getTimeOfEvent(moonrise),
|
|
210
|
+
moonset: naturalDate.getTimeOfEvent(moonset)
|
|
211
|
+
};
|
|
131
212
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
213
|
+
astroCache.set(cacheKey, events);
|
|
214
|
+
return events;
|
|
215
|
+
} catch (error) {
|
|
216
|
+
console.error('Error calculating moon events:', error);
|
|
217
|
+
return {
|
|
218
|
+
moonrise: null,
|
|
219
|
+
moonset: null
|
|
220
|
+
}
|
|
135
221
|
}
|
|
136
222
|
}
|
|
137
223
|
|
|
138
224
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* @
|
|
143
|
-
*
|
|
225
|
+
* Calculates the range of sun positions between winter and summer solstices (average mustaches angle)
|
|
226
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
227
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
228
|
+
* @returns {{
|
|
229
|
+
* winterSunrise: number,
|
|
230
|
+
* winterSunset: number,
|
|
231
|
+
* summerSunrise: number,
|
|
232
|
+
* summerSunset: number,
|
|
233
|
+
* averageMustacheAngle: number
|
|
234
|
+
* }} Solstice sun positions and mustache angle
|
|
144
235
|
*/
|
|
145
236
|
export function MustachesRange(naturalDate, latitude) {
|
|
146
|
-
|
|
237
|
+
const cacheKey = `MUSTACHES_${naturalDate.year}_${latitude}`;
|
|
147
238
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
239
|
+
try {
|
|
240
|
+
if (astroCache.has(cacheKey)) {
|
|
241
|
+
return astroCache.get(cacheKey);
|
|
242
|
+
}
|
|
151
243
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
244
|
+
const currentYear = new Date(naturalDate.unixTime).getFullYear();
|
|
245
|
+
const currentSeasons = Seasons(currentYear);
|
|
246
|
+
|
|
247
|
+
const winterSolsticeSunEvents = NaturalSunEvents(
|
|
248
|
+
new NaturalDate(currentSeasons.dec_solstice.date, 0),
|
|
249
|
+
latitude
|
|
250
|
+
);
|
|
251
|
+
const summerSolsticeSunEvents = NaturalSunEvents(
|
|
252
|
+
new NaturalDate(currentSeasons.jun_solstice.date, 0),
|
|
253
|
+
latitude
|
|
254
|
+
);
|
|
156
255
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
256
|
+
const averageMustacheAngle = Math.min(Math.max(
|
|
257
|
+
latitude >= 0
|
|
258
|
+
? (winterSolsticeSunEvents.sunrise - summerSolsticeSunEvents.sunrise +
|
|
259
|
+
summerSolsticeSunEvents.sunset - winterSolsticeSunEvents.sunset) / 4
|
|
260
|
+
: (summerSolsticeSunEvents.sunrise - winterSolsticeSunEvents.sunrise +
|
|
261
|
+
winterSolsticeSunEvents.sunset - summerSolsticeSunEvents.sunset) / 4,
|
|
262
|
+
0
|
|
263
|
+
), 90);
|
|
264
|
+
|
|
265
|
+
const result = {
|
|
266
|
+
winterSunrise: winterSolsticeSunEvents.sunrise,
|
|
267
|
+
winterSunset: winterSolsticeSunEvents.sunset,
|
|
268
|
+
summerSunrise: summerSolsticeSunEvents.sunrise,
|
|
269
|
+
summerSunset: summerSolsticeSunEvents.sunset,
|
|
270
|
+
averageMustacheAngle
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
astroCache.set(cacheKey, result);
|
|
274
|
+
return result;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
console.error('Error calculating mustaches range:', error);
|
|
277
|
+
return {
|
|
278
|
+
winterSunrise: 0,
|
|
279
|
+
winterSunset: 0,
|
|
280
|
+
summerSunrise: 0,
|
|
281
|
+
summerSunset: 0,
|
|
282
|
+
averageMustacheAngle: 0
|
|
283
|
+
}
|
|
284
|
+
}
|
|
171
285
|
}
|
package/index.js
CHANGED
|
@@ -1,28 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module natural-time-js
|
|
3
|
+
* @description Natural time implementation for JavaScript - translates artificial dates to natural dates
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import { Seasons } from 'astronomy-engine';
|
|
7
|
+
import { MILLISECONDS_PER_DAY, END_OF_ARTIFICIAL_TIME } from './constants.js';
|
|
2
8
|
|
|
3
|
-
const
|
|
9
|
+
const yearContextCache = new Map();
|
|
4
10
|
|
|
5
11
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Special thanks to Don Cross, author of the Astronomy Library to calculate celestial body events
|
|
16
|
-
* https://github.com/cosinekitty/astronomy
|
|
17
|
-
*
|
|
18
|
-
* @author Biquette 🐐
|
|
19
|
-
* @contact sylvain441@proton.me
|
|
20
|
-
*
|
|
12
|
+
* Calculates year context for natural date calculations
|
|
13
|
+
* @param {number} artificialYear - Gregorian calendar year
|
|
14
|
+
* @param {number} longitude - Longitude in degrees (-180 to 180)
|
|
15
|
+
* @returns {{
|
|
16
|
+
* start: number,
|
|
17
|
+
* duration: number
|
|
18
|
+
* }} Year context with start timestamp and duration
|
|
19
|
+
* @private
|
|
21
20
|
*/
|
|
22
|
-
|
|
21
|
+
const calculateYearContext = (artificialYear, longitude) => {
|
|
22
|
+
const cacheKey = `${artificialYear}_${longitude}`;
|
|
23
|
+
|
|
24
|
+
if (yearContextCache.has(cacheKey)) {
|
|
25
|
+
return yearContextCache.get(cacheKey);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const startSolstice = Seasons(artificialYear).dec_solstice.date;
|
|
29
|
+
const endSolstice = Seasons(artificialYear + 1).dec_solstice.date;
|
|
30
|
+
|
|
31
|
+
const startNewYear = Date.UTC(
|
|
32
|
+
startSolstice.getUTCFullYear(),
|
|
33
|
+
startSolstice.getUTCMonth(),
|
|
34
|
+
startSolstice.getUTCDate() + (startSolstice.getUTCHours() >= 12 ? 1 : 0),
|
|
35
|
+
12, 0, 0
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const endNewYear = Date.UTC(
|
|
39
|
+
endSolstice.getUTCFullYear(),
|
|
40
|
+
endSolstice.getUTCMonth(),
|
|
41
|
+
endSolstice.getUTCDate() + (endSolstice.getUTCHours() >= 12 ? 1 : 0),
|
|
42
|
+
12, 0, 0
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const context = {
|
|
46
|
+
start: parseInt(startNewYear + (-longitude + 180) * MILLISECONDS_PER_DAY/360),
|
|
47
|
+
duration: (endNewYear - startNewYear) / MILLISECONDS_PER_DAY
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
yearContextCache.set(cacheKey, context);
|
|
51
|
+
return context;
|
|
52
|
+
}
|
|
23
53
|
|
|
24
|
-
|
|
25
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Natural date class for converting artificial (Gregorian) dates to natural time
|
|
56
|
+
* @class
|
|
57
|
+
*/
|
|
58
|
+
export class NaturalDate {
|
|
26
59
|
|
|
27
60
|
unixTime; // Artificial gregorian date (UNIX timestamp)
|
|
28
61
|
longitude; // Longitude (between -180° to +180°)
|
|
@@ -47,62 +80,40 @@ export class NaturalDate {
|
|
|
47
80
|
nadir; // Beginning of the day at the current longitude (UNIX timestamp)
|
|
48
81
|
|
|
49
82
|
/**
|
|
50
|
-
*
|
|
51
|
-
* @param {Date} date Date object or Unix timestamp
|
|
52
|
-
* @param {
|
|
83
|
+
* Creates a new natural date instance
|
|
84
|
+
* @param {Date|number} date - JavaScript Date object or Unix timestamp
|
|
85
|
+
* @param {number} longitude - Longitude in degrees (-180 to 180)
|
|
86
|
+
* @throws {Error} If inputs are invalid
|
|
53
87
|
*/
|
|
54
88
|
constructor(date, longitude) {
|
|
55
|
-
|
|
89
|
+
// Validate longitude
|
|
90
|
+
if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) {
|
|
91
|
+
throw new Error('Longitude must be between -180 and +180');
|
|
92
|
+
}
|
|
93
|
+
|
|
56
94
|
date = new Date(date || Date.now());
|
|
95
|
+
if (isNaN(date.getTime())) {
|
|
96
|
+
throw new Error('Invalid date provided');
|
|
97
|
+
}
|
|
98
|
+
|
|
57
99
|
this.unixTime = date.getTime();
|
|
58
100
|
this.longitude = longitude || 0;
|
|
59
101
|
|
|
60
102
|
if (Number.isFinite(this.unixTime)) {
|
|
61
|
-
|
|
62
|
-
const getYearContext = (artificialYear, longitude) => {
|
|
63
|
-
|
|
64
|
-
// We cache astronomical calculations
|
|
65
|
-
let cache_id = `${artificialYear}_${longitude}`;
|
|
66
|
-
if(NT_CACHE?.[cache_id])
|
|
67
|
-
return NT_CACHE[cache_id];
|
|
68
|
-
|
|
69
|
-
// Search for December solstices
|
|
70
|
-
let startSolstice = Seasons(artificialYear).dec_solstice.date;
|
|
71
|
-
let endSolstice = Seasons(artificialYear + 1).dec_solstice.date;
|
|
72
|
-
|
|
73
|
-
// Calculate next time it's midnight at the 180th meridian after solstice (mean time)
|
|
74
|
-
// NB: Midnight at +180° equals midday the previous day at +0°
|
|
75
|
-
let startNewYear = Date.UTC(
|
|
76
|
-
startSolstice.getUTCFullYear(),
|
|
77
|
-
startSolstice.getUTCMonth(),
|
|
78
|
-
startSolstice.getUTCDate() + (startSolstice.getUTCHours() >= 12 ? 1 : 0),
|
|
79
|
-
12, 0, 0);
|
|
80
|
-
let endNewYear = Date.UTC(
|
|
81
|
-
endSolstice.getUTCFullYear(),
|
|
82
|
-
endSolstice.getUTCMonth(),
|
|
83
|
-
endSolstice.getUTCDate() + (endSolstice.getUTCHours() >= 12 ? 1 : 0),
|
|
84
|
-
12, 0, 0);
|
|
85
|
-
|
|
86
|
-
return NT_CACHE[cache_id] = {
|
|
87
|
-
start: parseInt(startNewYear + (-longitude + 180) * NaturalDate.MILLISECONDS_PER_DAY/360),
|
|
88
|
-
duration: (endNewYear - startNewYear) / NaturalDate.MILLISECONDS_PER_DAY
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
103
|
// YEAR START & DURATION
|
|
93
|
-
let yearContext =
|
|
104
|
+
let yearContext = calculateYearContext(date.getUTCFullYear()-1, longitude);
|
|
94
105
|
|
|
95
106
|
// Correction if between the beginning of natural year and the end of the artificial year
|
|
96
|
-
if(this.unixTime - yearContext.start >= yearContext.duration *
|
|
97
|
-
yearContext =
|
|
107
|
+
if(this.unixTime - yearContext.start >= yearContext.duration * MILLISECONDS_PER_DAY)
|
|
108
|
+
yearContext = calculateYearContext(date.getUTCFullYear(), longitude);
|
|
98
109
|
|
|
99
110
|
this.yearStart = yearContext.start;
|
|
100
111
|
this.yearDuration = yearContext.duration;
|
|
101
112
|
|
|
102
|
-
let timeSinceLocalYearStart = (this.unixTime - this.yearStart) /
|
|
113
|
+
let timeSinceLocalYearStart = (this.unixTime - this.yearStart) / MILLISECONDS_PER_DAY;
|
|
103
114
|
|
|
104
115
|
// YEAR
|
|
105
|
-
this.year = new Date(this.yearStart).getUTCFullYear() - new Date(
|
|
116
|
+
this.year = new Date(this.yearStart).getUTCFullYear() - new Date(END_OF_ARTIFICIAL_TIME).getUTCFullYear()+ 1;
|
|
106
117
|
|
|
107
118
|
// MOON
|
|
108
119
|
this.moon = parseInt(timeSinceLocalYearStart / 28) + 1;
|
|
@@ -112,16 +123,16 @@ export class NaturalDate {
|
|
|
112
123
|
this.weekOfMoon = parseInt(timeSinceLocalYearStart / 7) % 4 + 1;
|
|
113
124
|
|
|
114
125
|
// DAY
|
|
115
|
-
this.day = parseInt((this.unixTime - (
|
|
126
|
+
this.day = parseInt((this.unixTime - (END_OF_ARTIFICIAL_TIME + (-longitude + 180) * MILLISECONDS_PER_DAY/360)) / MILLISECONDS_PER_DAY);
|
|
116
127
|
this.dayOfYear = parseInt(timeSinceLocalYearStart) + 1;
|
|
117
128
|
this.dayOfMoon = parseInt(timeSinceLocalYearStart) % 28 + 1;
|
|
118
129
|
this.dayOfWeek = parseInt(timeSinceLocalYearStart) % 7 + 1;
|
|
119
130
|
|
|
120
131
|
// NADIR (i.e day start, midnight)
|
|
121
|
-
this.nadir = this.yearStart + parseInt(timeSinceLocalYearStart) *
|
|
132
|
+
this.nadir = this.yearStart + parseInt(timeSinceLocalYearStart) * MILLISECONDS_PER_DAY;
|
|
122
133
|
|
|
123
134
|
// TIME
|
|
124
|
-
this.time = (this.unixTime - this.nadir) * 360 /
|
|
135
|
+
this.time = (this.unixTime - this.nadir) * 360 / MILLISECONDS_PER_DAY;
|
|
125
136
|
|
|
126
137
|
// RAINBOW DAY
|
|
127
138
|
this.isRainbowDay = this.dayOfYear > 13*28;
|
|
@@ -133,19 +144,19 @@ export class NaturalDate {
|
|
|
133
144
|
}
|
|
134
145
|
|
|
135
146
|
/**
|
|
136
|
-
*
|
|
137
|
-
* @param {Date} event
|
|
138
|
-
* @returns
|
|
147
|
+
* Gets the time of an astronomical event in natural degrees
|
|
148
|
+
* @param {Date|number} event - Event timestamp
|
|
149
|
+
* @returns {number|false} Event time in natural degrees (0-360) or false if out of range
|
|
139
150
|
*/
|
|
140
151
|
getTimeOfEvent(event) {
|
|
141
152
|
// Make sure it's a unix timestamp
|
|
142
153
|
event = new Date(event).getTime();
|
|
143
154
|
|
|
144
155
|
// Check if not out of range
|
|
145
|
-
if(event < this.nadir || event > this.nadir +
|
|
156
|
+
if(event < this.nadir || event > this.nadir + MILLISECONDS_PER_DAY)
|
|
146
157
|
return false;
|
|
147
158
|
|
|
148
|
-
return (event - this.nadir) * (360 /
|
|
159
|
+
return (event - this.nadir) * (360 / MILLISECONDS_PER_DAY);
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
/**
|
|
@@ -153,7 +164,7 @@ export class NaturalDate {
|
|
|
153
164
|
* @returns ex: "004)04)01 113°00 NT+95"
|
|
154
165
|
*/
|
|
155
166
|
toString() {
|
|
156
|
-
return this.toDateString()
|
|
167
|
+
return `${this.toDateString()} ${this.toTimeString()} ${this.toLongitudeString()}`;
|
|
157
168
|
}
|
|
158
169
|
|
|
159
170
|
/**
|
|
@@ -162,10 +173,10 @@ export class NaturalDate {
|
|
|
162
173
|
* @returns ex: "004)04)01"
|
|
163
174
|
*/
|
|
164
175
|
toDateString(separator = ')') {
|
|
165
|
-
if(this.isRainbowDay)
|
|
166
|
-
return `${this.toYearString()}${separator}RAINBOW${this.dayOfYear
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
if(this.isRainbowDay) {
|
|
177
|
+
return `${this.toYearString()}${separator}RAINBOW${this.dayOfYear === 366 ? "+" : ""}`;
|
|
178
|
+
}
|
|
179
|
+
return `${this.toYearString()}${separator}${this.toMoonString()}${separator}${this.toDayOfMoonString()}`;
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "natural-time-js",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Natural time is a fresh, elegant, and coherent way of measuring the movements of time here on the Earth. This new time standard is based on common sense and the observation of natural cycles. Learn more: https://naturaltime.app. This JavaScript Class translates gregorian artificial datetime to natural datetime",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module validators
|
|
3
|
+
* @description Validation utilities for natural time calculations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { NaturalDate } from '../index.js';
|
|
7
|
+
|
|
8
|
+
const LATITUDE_RANGE = { MIN: -90, MAX: 90 };
|
|
9
|
+
const LONGITUDE_RANGE = { MIN: -180, MAX: 180 };
|
|
10
|
+
const ANGLE_RANGE = { MIN: 0, MAX: 360 };
|
|
11
|
+
|
|
12
|
+
// Add ranges based on NaturalDate class constraints
|
|
13
|
+
const NATURAL_DATE_RANGES = {
|
|
14
|
+
MOON: { MIN: 1, MAX: 14 },
|
|
15
|
+
WEEK: { MIN: 1, MAX: 53 },
|
|
16
|
+
WEEK_OF_MOON: { MIN: 1, MAX: 4 },
|
|
17
|
+
DAY_OF_MOON: { MIN: 1, MAX: 28 },
|
|
18
|
+
DAY_OF_WEEK: { MIN: 1, MAX: 7 }
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Validates if a value is a finite number
|
|
23
|
+
* @param {*} value - Value to validate
|
|
24
|
+
* @returns {boolean} True if value is a valid number
|
|
25
|
+
*/
|
|
26
|
+
export const isValidNumber = (value) => {
|
|
27
|
+
return typeof value === 'number' && Number.isFinite(value) && !Number.isNaN(value);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validates latitude value
|
|
32
|
+
* @param {number} latitude - Latitude to validate
|
|
33
|
+
* @returns {boolean} True if latitude is valid
|
|
34
|
+
*/
|
|
35
|
+
export const isValidLatitude = (latitude) => {
|
|
36
|
+
return isValidNumber(latitude) &&
|
|
37
|
+
latitude >= LATITUDE_RANGE.MIN &&
|
|
38
|
+
latitude <= LATITUDE_RANGE.MAX;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validates longitude value
|
|
43
|
+
*/
|
|
44
|
+
export const isValidLongitude = (longitude) => {
|
|
45
|
+
return isValidNumber(longitude) &&
|
|
46
|
+
longitude >= LONGITUDE_RANGE.MIN &&
|
|
47
|
+
longitude <= LONGITUDE_RANGE.MAX;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validates angle value (0-360 degrees)
|
|
52
|
+
*/
|
|
53
|
+
export const isValidAngle = (angle) => {
|
|
54
|
+
return isValidNumber(angle) &&
|
|
55
|
+
angle >= ANGLE_RANGE.MIN &&
|
|
56
|
+
angle <= ANGLE_RANGE.MAX;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Validates unix timestamp
|
|
61
|
+
*/
|
|
62
|
+
export const isValidTimestamp = (timestamp) => {
|
|
63
|
+
return isValidNumber(timestamp) && timestamp > 0;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Validates NaturalDate instance with complete property checks
|
|
68
|
+
* @param {*} date - Value to validate
|
|
69
|
+
* @returns {boolean} True if value is a valid NaturalDate instance
|
|
70
|
+
*/
|
|
71
|
+
export const isValidNaturalDate = (date) => {
|
|
72
|
+
if (!(date instanceof NaturalDate)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Basic property validations
|
|
77
|
+
if (!isValidTimestamp(date.unixTime) ||
|
|
78
|
+
!isValidLongitude(date.longitude) ||
|
|
79
|
+
!isValidNumber(date.year)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Natural calendar validations
|
|
84
|
+
return (
|
|
85
|
+
isValidNumber(date.moon) &&
|
|
86
|
+
date.moon >= NATURAL_DATE_RANGES.MOON.MIN &&
|
|
87
|
+
date.moon <= NATURAL_DATE_RANGES.MOON.MAX &&
|
|
88
|
+
|
|
89
|
+
isValidNumber(date.week) &&
|
|
90
|
+
date.week >= NATURAL_DATE_RANGES.WEEK.MIN &&
|
|
91
|
+
date.week <= NATURAL_DATE_RANGES.WEEK.MAX &&
|
|
92
|
+
|
|
93
|
+
isValidNumber(date.weekOfMoon) &&
|
|
94
|
+
date.weekOfMoon >= NATURAL_DATE_RANGES.WEEK_OF_MOON.MIN &&
|
|
95
|
+
date.weekOfMoon <= NATURAL_DATE_RANGES.WEEK_OF_MOON.MAX &&
|
|
96
|
+
|
|
97
|
+
isValidNumber(date.dayOfMoon) &&
|
|
98
|
+
date.dayOfMoon >= NATURAL_DATE_RANGES.DAY_OF_MOON.MIN &&
|
|
99
|
+
date.dayOfMoon <= NATURAL_DATE_RANGES.DAY_OF_MOON.MAX &&
|
|
100
|
+
|
|
101
|
+
isValidNumber(date.dayOfWeek) &&
|
|
102
|
+
date.dayOfWeek >= NATURAL_DATE_RANGES.DAY_OF_WEEK.MIN &&
|
|
103
|
+
date.dayOfWeek <= NATURAL_DATE_RANGES.DAY_OF_WEEK.MAX &&
|
|
104
|
+
|
|
105
|
+
isValidNumber(date.dayOfYear) &&
|
|
106
|
+
date.dayOfYear >= 1 &&
|
|
107
|
+
date.dayOfYear <= date.yearDuration &&
|
|
108
|
+
|
|
109
|
+
typeof date.isRainbowDay === 'boolean' &&
|
|
110
|
+
isValidAngle(date.time) &&
|
|
111
|
+
isValidTimestamp(date.yearStart) &&
|
|
112
|
+
isValidNumber(date.yearDuration) &&
|
|
113
|
+
isValidTimestamp(date.nadir)
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Validates cache key string
|
|
119
|
+
*/
|
|
120
|
+
export const isValidCacheKey = (key) => {
|
|
121
|
+
return typeof key === 'string' && key.length > 0;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Throws formatted error for invalid parameters
|
|
126
|
+
* @param {string} paramName - Name of the invalid parameter
|
|
127
|
+
* @param {*} value - Invalid value
|
|
128
|
+
* @param {string} expectedType - Description of expected type/format
|
|
129
|
+
* @throws {Error} Formatted validation error
|
|
130
|
+
*/
|
|
131
|
+
export const throwValidationError = (paramName, value, expectedType) => {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Invalid ${paramName}: ${value}. ` +
|
|
134
|
+
`Expected ${expectedType}`
|
|
135
|
+
);
|
|
136
|
+
};
|