natural-time-js 1.0.5 → 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/context.js +275 -126
- package/index.js +84 -63
- package/package.json +1 -1
- package/utils/validators.js +136 -0
package/context.js
CHANGED
|
@@ -1,171 +1,320 @@
|
|
|
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 {
|
|
9
|
+
isValidLatitude,
|
|
10
|
+
isValidLongitude,
|
|
11
|
+
isValidNaturalDate,
|
|
12
|
+
throwValidationError
|
|
13
|
+
} from './utils/validators.js';
|
|
3
14
|
|
|
4
|
-
|
|
5
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Hemisphere identifiers for geographical calculations
|
|
17
|
+
* @constant
|
|
18
|
+
* @enum {string}
|
|
19
|
+
* @readonly
|
|
20
|
+
*/
|
|
21
|
+
export const HEMISPHERES = {
|
|
22
|
+
NORTH: 'NORTH',
|
|
23
|
+
SOUTH: 'SOUTH'
|
|
24
|
+
};
|
|
6
25
|
|
|
7
26
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* @
|
|
12
|
-
* @
|
|
13
|
-
* @returns Object { altitude, highestAltitude }
|
|
27
|
+
* Day numbers marking the start and end of summer season
|
|
28
|
+
* Day 1 represents January 1st
|
|
29
|
+
* @constant
|
|
30
|
+
* @enum {number}
|
|
31
|
+
* @readonly
|
|
14
32
|
*/
|
|
15
|
-
export
|
|
33
|
+
export const SEASONS = {
|
|
34
|
+
/** Day 91 (typically April 1st) */
|
|
35
|
+
SUMMER_START_DAY: 91,
|
|
36
|
+
/** Day 273 (typically September 30th) */
|
|
37
|
+
SUMMER_END_DAY: 273
|
|
38
|
+
};
|
|
16
39
|
|
|
17
|
-
|
|
18
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Solar altitude thresholds in degrees for different daylight conditions
|
|
42
|
+
* @constant
|
|
43
|
+
* @enum {number}
|
|
44
|
+
* @readonly
|
|
45
|
+
*/
|
|
46
|
+
export const ANGLES = {
|
|
47
|
+
NIGHT_ALTITUDE: -12,
|
|
48
|
+
GOLDEN_HOUR_ALTITUDE: 6
|
|
49
|
+
};
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
let midday = SearchHourAngle(Body.Sun, observer, 0, new Date(naturalDate.nadir));
|
|
51
|
+
// Move cache to its own module for better separation of concerns
|
|
52
|
+
const astroCache = new Map();
|
|
23
53
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Determines the hemisphere based on latitude
|
|
56
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
57
|
+
* @returns {string} HEMISPHERES.NORTH or HEMISPHERES.SOUTH
|
|
58
|
+
* @private
|
|
59
|
+
*/
|
|
60
|
+
const getHemisphere = (latitude) => latitude >= 0 ? HEMISPHERES.NORTH : HEMISPHERES.SOUTH;
|
|
29
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Determines if the given day is in summer season for the specified latitude
|
|
64
|
+
* @param {number} dayOfYear - Day of the year (1-366)
|
|
65
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
66
|
+
* @returns {boolean} True if the day is in summer season
|
|
67
|
+
* @private
|
|
68
|
+
*/
|
|
69
|
+
const isSummerSeason = (dayOfYear, latitude) => {
|
|
70
|
+
const hemisphere = getHemisphere(latitude);
|
|
71
|
+
return hemisphere === HEMISPHERES.NORTH
|
|
72
|
+
? (dayOfYear >= SEASONS.SUMMER_START_DAY && dayOfYear <= SEASONS.SUMMER_END_DAY)
|
|
73
|
+
: (dayOfYear <= SEASONS.SUMMER_START_DAY || dayOfYear >= SEASONS.SUMMER_END_DAY);
|
|
74
|
+
}
|
|
30
75
|
|
|
31
76
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* @
|
|
36
|
-
* @
|
|
37
|
-
* @returns Object { sunrise, sunset, nightStart, nightEnd, morningGoldenHour, eveningGoldenHour }
|
|
77
|
+
* Creates an astronomical observer at the specified coordinates
|
|
78
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
79
|
+
* @param {number} longitude - Longitude in degrees (-180 to 180)
|
|
80
|
+
* @returns {Observer} Astronomy-engine observer instance
|
|
81
|
+
* @private
|
|
38
82
|
*/
|
|
39
|
-
|
|
83
|
+
const createObserver = (latitude, longitude) => new Observer(latitude, longitude, 0);
|
|
40
84
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Calculates sun altitude data for a given natural date and latitude
|
|
87
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
88
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
89
|
+
* @returns {{
|
|
90
|
+
* altitude: number,
|
|
91
|
+
* highestAltitude: number
|
|
92
|
+
* }} Sun altitude data
|
|
93
|
+
* @throws {Error} If inputs are invalid or calculation fails
|
|
94
|
+
*/
|
|
95
|
+
export function NaturalSunAltitude(naturalDate, latitude) {
|
|
96
|
+
// Validate inputs
|
|
97
|
+
if (!isValidNaturalDate(naturalDate)) {
|
|
98
|
+
throwValidationError('naturalDate', naturalDate, 'NaturalDate instance');
|
|
99
|
+
}
|
|
100
|
+
if (!isValidLatitude(latitude)) {
|
|
101
|
+
throwValidationError('latitude', latitude, 'number between -90 and 90');
|
|
45
102
|
}
|
|
46
103
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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;
|
|
71
|
-
|
|
72
|
-
return ASTRO_CACHE[cache_id] = {
|
|
73
|
-
sunrise: sunrise,
|
|
74
|
-
sunset: sunset,
|
|
75
|
-
nightStart: nightStart,
|
|
76
|
-
nightEnd: nightEnd,
|
|
77
|
-
morningGoldenHour: morningGoldenHour,
|
|
78
|
-
eveningGoldenHour: eveningGoldenHour
|
|
104
|
+
try {
|
|
105
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
106
|
+
const date = new Date(naturalDate.unixTime);
|
|
107
|
+
const sun = Equator(Body.Sun, date, observer, true, false);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
altitude: Math.max(Horizon(date, observer, sun.ra, sun.dec).altitude, 0),
|
|
111
|
+
highestAltitude: SearchHourAngle(Body.Sun, observer, 0, new Date(naturalDate.nadir)).hor.altitude
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error('Error in NaturalSunAltitude:', error);
|
|
115
|
+
throw error;
|
|
79
116
|
}
|
|
80
117
|
}
|
|
81
118
|
|
|
82
119
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* @
|
|
87
|
-
*
|
|
88
|
-
*
|
|
120
|
+
* Calculates sun-related events for a given natural date and latitude
|
|
121
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
122
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
123
|
+
* @returns {{
|
|
124
|
+
* sunrise: number,
|
|
125
|
+
* sunset: number,
|
|
126
|
+
* nightStart: number,
|
|
127
|
+
* nightEnd: number,
|
|
128
|
+
* morningGoldenHour: number,
|
|
129
|
+
* eveningGoldenHour: number
|
|
130
|
+
* }} Sun events in natural degrees (0-360)
|
|
131
|
+
* @throws {Error} If inputs are invalid or calculation fails
|
|
89
132
|
*/
|
|
90
|
-
export function
|
|
133
|
+
export function NaturalSunEvents(naturalDate, latitude) {
|
|
134
|
+
// Validate inputs
|
|
135
|
+
if (!isValidNaturalDate(naturalDate)) {
|
|
136
|
+
throwValidationError('naturalDate', naturalDate, 'NaturalDate instance');
|
|
137
|
+
}
|
|
138
|
+
if (!isValidLatitude(latitude)) {
|
|
139
|
+
throwValidationError('latitude', latitude, 'number between -90 and 90');
|
|
140
|
+
}
|
|
91
141
|
|
|
92
|
-
|
|
93
|
-
|
|
142
|
+
const cacheKey = `SUN_${naturalDate.toDateString()}_${latitude}_${naturalDate.longitude}`;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (astroCache.has(cacheKey)) {
|
|
146
|
+
const cachedResult = astroCache.get(cacheKey);
|
|
147
|
+
if (cachedResult && typeof cachedResult === 'object') {
|
|
148
|
+
return cachedResult;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
153
|
+
const nadir = new Date(naturalDate.nadir);
|
|
154
|
+
const isSummer = isSummerSeason(naturalDate.dayOfYear, latitude);
|
|
94
155
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
// Helper function to handle edge cases
|
|
157
|
+
const getEventTime = (searchResult, isSummerDefault) => {
|
|
158
|
+
if (!searchResult) {
|
|
159
|
+
return isSummer ? (isSummerDefault ? 360 : 0) : 180;
|
|
160
|
+
}
|
|
161
|
+
return naturalDate.getTimeOfEvent(searchResult);
|
|
162
|
+
};
|
|
98
163
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
164
|
+
const events = {
|
|
165
|
+
sunrise: getEventTime(SearchRiseSet(Body.Sun, observer, +1, nadir, 1), false),
|
|
166
|
+
sunset: getEventTime(SearchRiseSet(Body.Sun, observer, -1, nadir, 1), true),
|
|
167
|
+
nightStart: getEventTime(SearchAltitude(Body.Sun, observer, -1, nadir, 2, ANGLES.NIGHT_ALTITUDE), true),
|
|
168
|
+
nightEnd: getEventTime(SearchAltitude(Body.Sun, observer, +1, nadir, 2, ANGLES.NIGHT_ALTITUDE), false),
|
|
169
|
+
morningGoldenHour: getEventTime(SearchAltitude(Body.Sun, observer, +1, nadir, 2, ANGLES.GOLDEN_HOUR_ALTITUDE), false),
|
|
170
|
+
eveningGoldenHour: getEventTime(SearchAltitude(Body.Sun, observer, -1, nadir, 2, ANGLES.GOLDEN_HOUR_ALTITUDE), true)
|
|
171
|
+
};
|
|
102
172
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
173
|
+
astroCache.set(cacheKey, events);
|
|
174
|
+
return events;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('Error in NaturalSunEvents:', error);
|
|
177
|
+
throw error;
|
|
107
178
|
}
|
|
108
179
|
}
|
|
109
180
|
|
|
110
181
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* @
|
|
115
|
-
*
|
|
116
|
-
*
|
|
182
|
+
* Calculates moon position and phase data for a given natural date and latitude
|
|
183
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
184
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
185
|
+
* @returns {{
|
|
186
|
+
* phase: number,
|
|
187
|
+
* altitude: number,
|
|
188
|
+
* highestAltitude: number
|
|
189
|
+
* }} Moon position and phase data
|
|
117
190
|
*/
|
|
118
|
-
export function
|
|
191
|
+
export function NaturalMoonPosition(naturalDate, latitude) {
|
|
192
|
+
// Validate inputs
|
|
193
|
+
if (!isValidNaturalDate(naturalDate)) {
|
|
194
|
+
throwValidationError('naturalDate', naturalDate, 'NaturalDate instance');
|
|
195
|
+
}
|
|
196
|
+
if (!isValidLatitude(latitude)) {
|
|
197
|
+
throwValidationError('latitude', latitude, 'number between -90 and 90');
|
|
198
|
+
}
|
|
119
199
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
200
|
+
try {
|
|
201
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
202
|
+
const date = new Date(naturalDate.unixTime);
|
|
203
|
+
const moon = Equator(Body.Moon, date, observer, true, false);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
phase: MoonPhase(date),
|
|
207
|
+
altitude: Math.max(Horizon(date, observer, moon.ra, moon.dec).altitude, 0),
|
|
208
|
+
highestAltitude: SearchHourAngle(Body.Moon, observer, 0, new Date(naturalDate.nadir)).hor.altitude
|
|
209
|
+
}
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error('Error in NaturalMoonPosition:', error);
|
|
212
|
+
return {
|
|
213
|
+
phase: 0,
|
|
214
|
+
altitude: 0,
|
|
215
|
+
highestAltitude: 0
|
|
216
|
+
}
|
|
124
217
|
}
|
|
218
|
+
}
|
|
125
219
|
|
|
126
|
-
|
|
220
|
+
/**
|
|
221
|
+
* Calculates moon rise and set events for a given natural date and latitude
|
|
222
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
223
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
224
|
+
* @returns {{
|
|
225
|
+
* moonrise: number|null,
|
|
226
|
+
* moonset: number|null
|
|
227
|
+
* }} Moon events in natural degrees (0-360)
|
|
228
|
+
*/
|
|
229
|
+
export function NaturalMoonEvents(naturalDate, latitude) {
|
|
230
|
+
const cacheKey = `MOON_${naturalDate.toDateString()}_${latitude}_${naturalDate.longitude}`;
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
if (astroCache.has(cacheKey)) {
|
|
234
|
+
return astroCache.get(cacheKey);
|
|
235
|
+
}
|
|
127
236
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
let moonset = SearchRiseSet(Body.Moon, observer, -1, new Date(naturalDate.nadir), 1);
|
|
237
|
+
const observer = createObserver(latitude, naturalDate.longitude);
|
|
238
|
+
const nadir = new Date(naturalDate.nadir);
|
|
131
239
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
240
|
+
const moonrise = SearchRiseSet(Body.Moon, observer, +1, nadir, 1);
|
|
241
|
+
const moonset = SearchRiseSet(Body.Moon, observer, -1, nadir, 1);
|
|
242
|
+
|
|
243
|
+
const events = {
|
|
244
|
+
moonrise: naturalDate.getTimeOfEvent(moonrise),
|
|
245
|
+
moonset: naturalDate.getTimeOfEvent(moonset)
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
astroCache.set(cacheKey, events);
|
|
249
|
+
return events;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
console.error('Error calculating moon events:', error);
|
|
252
|
+
return {
|
|
253
|
+
moonrise: null,
|
|
254
|
+
moonset: null
|
|
255
|
+
}
|
|
135
256
|
}
|
|
136
257
|
}
|
|
137
258
|
|
|
138
259
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* @
|
|
143
|
-
*
|
|
260
|
+
* Calculates the range of sun positions between winter and summer solstices (average mustaches angle)
|
|
261
|
+
* @param {NaturalDate} naturalDate - Natural date instance
|
|
262
|
+
* @param {number} latitude - Latitude in degrees (-90 to 90)
|
|
263
|
+
* @returns {{
|
|
264
|
+
* winterSunrise: number,
|
|
265
|
+
* winterSunset: number,
|
|
266
|
+
* summerSunrise: number,
|
|
267
|
+
* summerSunset: number,
|
|
268
|
+
* averageMustacheAngle: number
|
|
269
|
+
* }} Solstice sun positions and mustache angle
|
|
144
270
|
*/
|
|
145
271
|
export function MustachesRange(naturalDate, latitude) {
|
|
146
|
-
|
|
272
|
+
const cacheKey = `MUSTACHES_${naturalDate.year}_${latitude}`;
|
|
147
273
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
274
|
+
try {
|
|
275
|
+
if (astroCache.has(cacheKey)) {
|
|
276
|
+
return astroCache.get(cacheKey);
|
|
277
|
+
}
|
|
151
278
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
279
|
+
const currentYear = new Date(naturalDate.unixTime).getFullYear();
|
|
280
|
+
const currentSeasons = Seasons(currentYear);
|
|
281
|
+
|
|
282
|
+
const winterSolsticeSunEvents = NaturalSunEvents(
|
|
283
|
+
new NaturalDate(currentSeasons.dec_solstice.date, 0),
|
|
284
|
+
latitude
|
|
285
|
+
);
|
|
286
|
+
const summerSolsticeSunEvents = NaturalSunEvents(
|
|
287
|
+
new NaturalDate(currentSeasons.jun_solstice.date, 0),
|
|
288
|
+
latitude
|
|
289
|
+
);
|
|
156
290
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
291
|
+
const averageMustacheAngle = Math.min(Math.max(
|
|
292
|
+
latitude >= 0
|
|
293
|
+
? (winterSolsticeSunEvents.sunrise - summerSolsticeSunEvents.sunrise +
|
|
294
|
+
summerSolsticeSunEvents.sunset - winterSolsticeSunEvents.sunset) / 4
|
|
295
|
+
: (summerSolsticeSunEvents.sunrise - winterSolsticeSunEvents.sunrise +
|
|
296
|
+
winterSolsticeSunEvents.sunset - summerSolsticeSunEvents.sunset) / 4,
|
|
297
|
+
0
|
|
298
|
+
), 90);
|
|
299
|
+
|
|
300
|
+
const result = {
|
|
301
|
+
winterSunrise: winterSolsticeSunEvents.sunrise,
|
|
302
|
+
winterSunset: winterSolsticeSunEvents.sunset,
|
|
303
|
+
summerSunrise: summerSolsticeSunEvents.sunrise,
|
|
304
|
+
summerSunset: summerSolsticeSunEvents.sunset,
|
|
305
|
+
averageMustacheAngle
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
astroCache.set(cacheKey, result);
|
|
309
|
+
return result;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error('Error calculating mustaches range:', error);
|
|
312
|
+
return {
|
|
313
|
+
winterSunrise: 0,
|
|
314
|
+
winterSunset: 0,
|
|
315
|
+
summerSunrise: 0,
|
|
316
|
+
summerSunset: 0,
|
|
317
|
+
averageMustacheAngle: 0
|
|
318
|
+
}
|
|
319
|
+
}
|
|
171
320
|
}
|
package/index.js
CHANGED
|
@@ -1,28 +1,71 @@
|
|
|
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';
|
|
2
7
|
|
|
3
|
-
const
|
|
8
|
+
const yearContextCache = new Map();
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
* Calculates year context for natural date calculations
|
|
12
|
+
* @param {number} artificialYear - Gregorian calendar year
|
|
13
|
+
* @param {number} longitude - Longitude in degrees (-180 to 180)
|
|
14
|
+
* @returns {{
|
|
15
|
+
* start: number,
|
|
16
|
+
* duration: number
|
|
17
|
+
* }} Year context with start timestamp and duration
|
|
18
|
+
* @private
|
|
19
|
+
*/
|
|
20
|
+
const calculateYearContext = (artificialYear, longitude) => {
|
|
21
|
+
const cacheKey = `${artificialYear}_${longitude}`;
|
|
22
|
+
|
|
23
|
+
if (yearContextCache.has(cacheKey)) {
|
|
24
|
+
return yearContextCache.get(cacheKey);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const startSolstice = Seasons(artificialYear).dec_solstice.date;
|
|
28
|
+
const endSolstice = Seasons(artificialYear + 1).dec_solstice.date;
|
|
29
|
+
|
|
30
|
+
const startNewYear = Date.UTC(
|
|
31
|
+
startSolstice.getUTCFullYear(),
|
|
32
|
+
startSolstice.getUTCMonth(),
|
|
33
|
+
startSolstice.getUTCDate() + (startSolstice.getUTCHours() >= 12 ? 1 : 0),
|
|
34
|
+
12, 0, 0
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const endNewYear = Date.UTC(
|
|
38
|
+
endSolstice.getUTCFullYear(),
|
|
39
|
+
endSolstice.getUTCMonth(),
|
|
40
|
+
endSolstice.getUTCDate() + (endSolstice.getUTCHours() >= 12 ? 1 : 0),
|
|
41
|
+
12, 0, 0
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const context = {
|
|
45
|
+
start: parseInt(startNewYear + (-longitude + 180) * NaturalDate.MILLISECONDS_PER_DAY/360),
|
|
46
|
+
duration: (endNewYear - startNewYear) / NaturalDate.MILLISECONDS_PER_DAY
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
yearContextCache.set(cacheKey, context);
|
|
50
|
+
return context;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Natural date class for converting artificial (Gregorian) dates to natural time
|
|
55
|
+
* @class
|
|
21
56
|
*/
|
|
22
57
|
export class NaturalDate {
|
|
58
|
+
/**
|
|
59
|
+
* Number of milliseconds in a day
|
|
60
|
+
* @constant {number}
|
|
61
|
+
*/
|
|
62
|
+
static MILLISECONDS_PER_DAY = 86400000; // 24 * 60 * 60 * 1000
|
|
23
63
|
|
|
24
|
-
|
|
25
|
-
|
|
64
|
+
/**
|
|
65
|
+
* End date of the artificial time era (December 21, 2012 12:00 UTC)
|
|
66
|
+
* @constant {number}
|
|
67
|
+
*/
|
|
68
|
+
static END_OF_ARTIFICIAL_TIME = Date.UTC(2012, 11, 21, 12, 0, 0);
|
|
26
69
|
|
|
27
70
|
unixTime; // Artificial gregorian date (UNIX timestamp)
|
|
28
71
|
longitude; // Longitude (between -180° to +180°)
|
|
@@ -47,54 +90,32 @@ export class NaturalDate {
|
|
|
47
90
|
nadir; // Beginning of the day at the current longitude (UNIX timestamp)
|
|
48
91
|
|
|
49
92
|
/**
|
|
50
|
-
*
|
|
51
|
-
* @param {Date} date Date object or Unix timestamp
|
|
52
|
-
* @param {
|
|
93
|
+
* Creates a new natural date instance
|
|
94
|
+
* @param {Date|number} date - JavaScript Date object or Unix timestamp
|
|
95
|
+
* @param {number} longitude - Longitude in degrees (-180 to 180)
|
|
96
|
+
* @throws {Error} If inputs are invalid
|
|
53
97
|
*/
|
|
54
98
|
constructor(date, longitude) {
|
|
55
|
-
|
|
99
|
+
// Validate longitude
|
|
100
|
+
if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) {
|
|
101
|
+
throw new Error('Longitude must be between -180 and +180');
|
|
102
|
+
}
|
|
103
|
+
|
|
56
104
|
date = new Date(date || Date.now());
|
|
105
|
+
if (isNaN(date.getTime())) {
|
|
106
|
+
throw new Error('Invalid date provided');
|
|
107
|
+
}
|
|
108
|
+
|
|
57
109
|
this.unixTime = date.getTime();
|
|
58
110
|
this.longitude = longitude || 0;
|
|
59
111
|
|
|
60
112
|
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
113
|
// YEAR START & DURATION
|
|
93
|
-
let yearContext =
|
|
114
|
+
let yearContext = calculateYearContext(date.getUTCFullYear()-1, longitude);
|
|
94
115
|
|
|
95
116
|
// Correction if between the beginning of natural year and the end of the artificial year
|
|
96
117
|
if(this.unixTime - yearContext.start >= yearContext.duration * NaturalDate.MILLISECONDS_PER_DAY)
|
|
97
|
-
yearContext =
|
|
118
|
+
yearContext = calculateYearContext(date.getUTCFullYear(), longitude);
|
|
98
119
|
|
|
99
120
|
this.yearStart = yearContext.start;
|
|
100
121
|
this.yearDuration = yearContext.duration;
|
|
@@ -133,9 +154,9 @@ export class NaturalDate {
|
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
/**
|
|
136
|
-
*
|
|
137
|
-
* @param {Date} event
|
|
138
|
-
* @returns
|
|
157
|
+
* Gets the time of an astronomical event in natural degrees
|
|
158
|
+
* @param {Date|number} event - Event timestamp
|
|
159
|
+
* @returns {number|false} Event time in natural degrees (0-360) or false if out of range
|
|
139
160
|
*/
|
|
140
161
|
getTimeOfEvent(event) {
|
|
141
162
|
// Make sure it's a unix timestamp
|
|
@@ -153,7 +174,7 @@ export class NaturalDate {
|
|
|
153
174
|
* @returns ex: "004)04)01 113°00 NT+95"
|
|
154
175
|
*/
|
|
155
176
|
toString() {
|
|
156
|
-
return this.toDateString()
|
|
177
|
+
return `${this.toDateString()} ${this.toTimeString()} ${this.toLongitudeString()}`;
|
|
157
178
|
}
|
|
158
179
|
|
|
159
180
|
/**
|
|
@@ -162,10 +183,10 @@ export class NaturalDate {
|
|
|
162
183
|
* @returns ex: "004)04)01"
|
|
163
184
|
*/
|
|
164
185
|
toDateString(separator = ')') {
|
|
165
|
-
if(this.isRainbowDay)
|
|
166
|
-
return `${this.toYearString()}${separator}RAINBOW${this.dayOfYear
|
|
167
|
-
|
|
168
|
-
|
|
186
|
+
if(this.isRainbowDay) {
|
|
187
|
+
return `${this.toYearString()}${separator}RAINBOW${this.dayOfYear === 366 ? "+" : ""}`;
|
|
188
|
+
}
|
|
189
|
+
return `${this.toYearString()}${separator}${this.toMoonString()}${separator}${this.toDayOfMoonString()}`;
|
|
169
190
|
}
|
|
170
191
|
|
|
171
192
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "natural-time-js",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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
|
+
};
|