rave-engine 1.0.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/src/index.js ADDED
@@ -0,0 +1,89 @@
1
+ const { parseBirthToUtc, BirthParseError } = require('./birth/parseBirth');
2
+ const {
3
+ computeEngineTest,
4
+ computeProfileSpheres,
5
+ computeActivations,
6
+ jdUtcFromDate,
7
+ findPreviousSolarLongitude,
8
+ signedAngleDiff,
9
+ } = require('./calc/profile');
10
+ const { mapLongitudeDegrees, normalizeAngleDegrees } = require('./calc/mandala');
11
+ const { ensureEphePath, getBackend } = require('./calc/ephemeris');
12
+ const { searchTimezones, formatOffset } = require('./timezone/search');
13
+ const { locationForTimezone, resolveLocation } = require('./timezone/location');
14
+ const {
15
+ CENTERS,
16
+ CENTER_LABELS,
17
+ GATE_CENTER,
18
+ CHANNELS,
19
+ computeBodygraph,
20
+ } = require('./hd/bodygraph');
21
+ const { computeAngles, computeHouses, signOf, SIGNS } = require('./hd/houses');
22
+ const { computeChart } = require('./chart');
23
+
24
+ /**
25
+ * High-level entry point: take human-friendly birth strings (date/time/timezone)
26
+ * and return the same `engine` payload that the API's /maintenance/engine_test
27
+ * endpoint produces, with the parsed input echoed back for traceability.
28
+ *
29
+ * For the full Gene Keys + Human Design + Astrology output, use `computeChart`.
30
+ *
31
+ * @param {{ birthdate: string, birthtime: string, timezone: string }} input
32
+ */
33
+ function computeProfile(input) {
34
+ const birthUtc = parseBirthToUtc(input);
35
+ return {
36
+ input: {
37
+ birthdate: input.birthdate,
38
+ birthtime: input.birthtime,
39
+ timezone: input.timezone,
40
+ birth_utc: birthUtc.toISOString(),
41
+ },
42
+ engine: computeEngineTest({ birthUtc }),
43
+ };
44
+ }
45
+
46
+ module.exports = {
47
+ // Top-level convenience
48
+ computeProfile,
49
+ computeChart,
50
+
51
+ // Birth parsing
52
+ parseBirthToUtc,
53
+ BirthParseError,
54
+
55
+ // Engine internals (parity with event-horizon-api)
56
+ computeEngineTest,
57
+ computeProfileSpheres,
58
+ computeActivations,
59
+ jdUtcFromDate,
60
+ findPreviousSolarLongitude,
61
+ signedAngleDiff,
62
+
63
+ // Mandala helpers
64
+ mapLongitudeDegrees,
65
+ normalizeAngleDegrees,
66
+
67
+ // Human Design bodygraph
68
+ computeBodygraph,
69
+ CENTERS,
70
+ CENTER_LABELS,
71
+ GATE_CENTER,
72
+ CHANNELS,
73
+
74
+ // Extended astrology
75
+ computeAngles,
76
+ computeHouses,
77
+ signOf,
78
+ SIGNS,
79
+
80
+ // Ephemeris backend (mostly internal; exposed for power users)
81
+ getBackend,
82
+ ensureEphePath,
83
+
84
+ // Timezone autocomplete + location
85
+ searchTimezones,
86
+ formatOffset,
87
+ locationForTimezone,
88
+ resolveLocation,
89
+ };
@@ -0,0 +1,67 @@
1
+ // Resolve a representative geographic location (lat/lng) for an IANA timezone.
2
+ //
3
+ // The extended-astrology section (Ascendant / MC / houses) needs a birth
4
+ // LOCATION, not just a timezone. To let a user "just pick the timezone they were
5
+ // born in" and still get angles, we derive a representative location from the
6
+ // most-populous city in that timezone (data from `city-timezones`). An explicit
7
+ // { lat, lng } always overrides this.
8
+
9
+ const cityTimezones = require('city-timezones');
10
+
11
+ let _byTz = null;
12
+
13
+ // Index cityMapping by IANA timezone → most-populous city, built once.
14
+ function buildIndex() {
15
+ if (_byTz) return _byTz;
16
+ const byTz = new Map();
17
+ for (const c of cityTimezones.cityMapping) {
18
+ if (!c || !c.timezone || !Number.isFinite(c.lat) || !Number.isFinite(c.lng)) continue;
19
+ const existing = byTz.get(c.timezone);
20
+ if (!existing || (c.pop || 0) > (existing.pop || 0)) {
21
+ byTz.set(c.timezone, c);
22
+ }
23
+ }
24
+ _byTz = byTz;
25
+ return _byTz;
26
+ }
27
+
28
+ /**
29
+ * Representative location for an IANA timezone (its most-populous city).
30
+ * @param {string} ianaName e.g. "Asia/Bangkok"
31
+ * @returns {{ lat:number, lng:number, city:string, province:string|null,
32
+ * country:string|null, source:'timezone-city' }|null}
33
+ */
34
+ function locationForTimezone(ianaName) {
35
+ if (!ianaName) return null;
36
+ const hit = buildIndex().get(ianaName);
37
+ if (!hit) return null;
38
+ return {
39
+ lat: hit.lat,
40
+ lng: hit.lng,
41
+ city: hit.city || hit.city_ascii || null,
42
+ province: hit.province || null,
43
+ country: hit.country || null,
44
+ source: 'timezone-city',
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Normalize a location input. Accepts either explicit coordinates or falls back
50
+ * to the timezone's representative city.
51
+ *
52
+ * @param {{ lat?:number, lng?:number, timezone?:string }} input
53
+ * @returns {{ lat:number, lng:number, source:string, city?, country? }|null}
54
+ */
55
+ function resolveLocation(input = {}) {
56
+ const { lat, lng, timezone } = input;
57
+ if (Number.isFinite(lat) && Number.isFinite(lng)) {
58
+ return { lat, lng, source: 'explicit' };
59
+ }
60
+ if (timezone) {
61
+ const loc = locationForTimezone(timezone);
62
+ if (loc) return loc;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ module.exports = { locationForTimezone, resolveLocation };
@@ -0,0 +1,183 @@
1
+ const { getTimeZones } = require('@vvo/tzdb');
2
+ const cityTimezones = require('city-timezones');
3
+
4
+ // Build the tzdb index once. ~400 entries.
5
+ //
6
+ // We index by lowercased IANA name for O(1) merge lookups, and keep a flat list
7
+ // for substring scanning. Each entry carries a precomputed `searchHaystack`
8
+ // string so scoring is just a few `indexOf` calls.
9
+ let _tzIndex = null;
10
+
11
+ function buildTzIndex() {
12
+ if (_tzIndex) return _tzIndex;
13
+ const list = getTimeZones();
14
+ const byIana = new Map();
15
+ const indexed = list.map((z) => {
16
+ const haystackParts = [
17
+ z.name,
18
+ z.alternativeName,
19
+ z.abbreviation,
20
+ z.countryName,
21
+ z.countryCode,
22
+ ...(z.mainCities || []),
23
+ ...(z.group || []),
24
+ ].filter(Boolean);
25
+
26
+ const entry = {
27
+ ianaName: z.name,
28
+ alternativeName: z.alternativeName,
29
+ countryName: z.countryName,
30
+ countryCode: z.countryCode,
31
+ mainCities: z.mainCities || [],
32
+ mainCity: (z.mainCities && z.mainCities[0]) || null,
33
+ currentOffsetMinutes: z.currentTimeOffsetInMinutes,
34
+ rawOffsetMinutes: z.rawOffsetInMinutes,
35
+ abbreviation: z.abbreviation,
36
+ currentTimeFormat: z.currentTimeFormat,
37
+ _haystack: haystackParts.join('\u0001').toLowerCase(),
38
+ _ianaLower: z.name.toLowerCase(),
39
+ _altLower: (z.alternativeName || '').toLowerCase(),
40
+ _citiesLower: (z.mainCities || []).map((c) => c.toLowerCase()),
41
+ };
42
+
43
+ byIana.set(entry._ianaLower, entry);
44
+ return entry;
45
+ });
46
+
47
+ _tzIndex = { list: indexed, byIana };
48
+ return _tzIndex;
49
+ }
50
+
51
+ function formatOffset(minutes) {
52
+ if (!Number.isFinite(minutes)) return '';
53
+ const sign = minutes >= 0 ? '+' : '-';
54
+ const abs = Math.abs(minutes);
55
+ const hh = String(Math.floor(abs / 60)).padStart(2, '0');
56
+ const mm = String(abs % 60).padStart(2, '0');
57
+ return `${sign}${hh}:${mm}`;
58
+ }
59
+
60
+ function buildLabel(entry, hintCity) {
61
+ const offset = formatOffset(entry.currentOffsetMinutes);
62
+ const city = hintCity || entry.mainCity;
63
+ const cityPart = city ? ` — ${city}` : '';
64
+ const countryPart = entry.countryName ? ` (${entry.countryName})` : '';
65
+ return `${entry.ianaName} ${offset}${cityPart}${countryPart}`.trim();
66
+ }
67
+
68
+ /**
69
+ * Score a tzdb entry against a lowercased query.
70
+ * Higher is better; 0 means "no match".
71
+ */
72
+ function scoreTzEntry(entry, q) {
73
+ if (!q) return 0;
74
+
75
+ // Strongest: exact IANA hit.
76
+ if (entry._ianaLower === q) return 1000;
77
+
78
+ // IANA prefix / contains.
79
+ if (entry._ianaLower.startsWith(q)) return 800;
80
+ // Match against the segment after a slash, e.g. "tokyo" → "asia/tokyo".
81
+ const lastSegment = entry._ianaLower.split('/').pop();
82
+ if (lastSegment.startsWith(q)) return 750;
83
+
84
+ // City prefix.
85
+ for (const c of entry._citiesLower) {
86
+ if (c === q) return 700;
87
+ if (c.startsWith(q)) return 650;
88
+ }
89
+
90
+ // Alternative name prefix (e.g. "central european" → "Central European Time").
91
+ if (entry._altLower.startsWith(q)) return 500;
92
+
93
+ // Generic contains anywhere in the haystack.
94
+ if (entry._haystack.includes(q)) return 200;
95
+
96
+ return 0;
97
+ }
98
+
99
+ /**
100
+ * Search timezones by free-text query.
101
+ *
102
+ * Combines @vvo/tzdb (rich IANA metadata + current offsets) with
103
+ * city-timezones (city → IANA lookup, including province/state matches like
104
+ * "Bali" → Asia/Makassar). Results are deduplicated by IANA name and ranked
105
+ * by a small scoring function.
106
+ *
107
+ * @param {string} query
108
+ * @param {{ limit?: number }} [options]
109
+ * @returns {Array<{
110
+ * ianaName: string,
111
+ * mainCity: string|null,
112
+ * countryName: string|null,
113
+ * currentOffsetMinutes: number,
114
+ * abbreviation: string|null,
115
+ * label: string,
116
+ * score: number,
117
+ * }>}
118
+ */
119
+ function searchTimezones(query, options = {}) {
120
+ const limit = Number.isFinite(options.limit) ? options.limit : 10;
121
+ const q = String(query || '').trim().toLowerCase();
122
+ if (!q) return [];
123
+
124
+ const { list, byIana } = buildTzIndex();
125
+
126
+ // 1) Score every tzdb entry directly.
127
+ const scored = new Map(); // ianaLower -> { entry, score, hintCity }
128
+ for (const entry of list) {
129
+ const s = scoreTzEntry(entry, q);
130
+ if (s > 0) {
131
+ scored.set(entry._ianaLower, { entry, score: s, hintCity: null });
132
+ }
133
+ }
134
+
135
+ // 2) Mine city-timezones for province/state-level hits and fold them in.
136
+ // findFromCityStateProvince does case-insensitive substring matching across
137
+ // city, state and province, which is what makes "bali" → Asia/Makassar work.
138
+ let cityHits = [];
139
+ try {
140
+ cityHits = cityTimezones.findFromCityStateProvince(q) || [];
141
+ } catch (_e) {
142
+ cityHits = [];
143
+ }
144
+
145
+ for (const hit of cityHits) {
146
+ if (!hit || !hit.timezone) continue;
147
+ const ianaLower = String(hit.timezone).toLowerCase();
148
+ const entry = byIana.get(ianaLower);
149
+ if (!entry) continue;
150
+
151
+ // City score: lower than direct IANA/city tzdb hits (which the loop above
152
+ // already caught), but better than a generic substring miss.
153
+ const cityScore = 600;
154
+
155
+ const existing = scored.get(ianaLower);
156
+ if (!existing || existing.score < cityScore) {
157
+ scored.set(ianaLower, { entry, score: cityScore, hintCity: hit.city });
158
+ } else if (!existing.hintCity && hit.city) {
159
+ // Keep the higher score, but enrich the label with the matched city name.
160
+ existing.hintCity = hit.city;
161
+ }
162
+ }
163
+
164
+ return Array.from(scored.values())
165
+ .sort((a, b) => {
166
+ if (b.score !== a.score) return b.score - a.score;
167
+ // Tie-break: prefer larger populations indirectly via tzdb's "main city"
168
+ // ordering. Falls back to alphabetical.
169
+ return a.entry.ianaName.localeCompare(b.entry.ianaName);
170
+ })
171
+ .slice(0, limit)
172
+ .map(({ entry, score, hintCity }) => ({
173
+ ianaName: entry.ianaName,
174
+ mainCity: entry.mainCity,
175
+ countryName: entry.countryName,
176
+ currentOffsetMinutes: entry.currentOffsetMinutes,
177
+ abbreviation: entry.abbreviation,
178
+ label: buildLabel(entry, hintCity),
179
+ score,
180
+ }));
181
+ }
182
+
183
+ module.exports = { searchTimezones, formatOffset };