ether-to-astro 1.2.0 → 1.3.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/README.md +15 -5
- package/dist/astro-service/chart-output-service.d.ts +44 -0
- package/dist/astro-service/chart-output-service.js +110 -0
- package/dist/astro-service/date-input.d.ts +14 -0
- package/dist/astro-service/date-input.js +30 -0
- package/dist/astro-service/electional-service.d.ts +45 -0
- package/dist/astro-service/electional-service.js +305 -0
- package/dist/astro-service/natal-service.d.ts +41 -0
- package/dist/astro-service/natal-service.js +179 -0
- package/dist/astro-service/rising-sign-service.d.ts +37 -0
- package/dist/astro-service/rising-sign-service.js +137 -0
- package/dist/astro-service/service-types.d.ts +82 -0
- package/dist/astro-service/service-types.js +1 -0
- package/dist/astro-service/shared.d.ts +65 -0
- package/dist/astro-service/shared.js +98 -0
- package/dist/astro-service/sky-service.d.ts +48 -0
- package/dist/astro-service/sky-service.js +144 -0
- package/dist/astro-service/transit-service.d.ts +82 -0
- package/dist/astro-service/transit-service.js +353 -0
- package/dist/astro-service.d.ts +101 -89
- package/dist/astro-service.js +162 -1042
- package/dist/tool-registry.js +1 -1
- package/docs/product/architecture-boundaries.md +8 -0
- package/docs/releases/1.3.0.md +51 -0
- package/docs/releases/README.md +17 -0
- package/package.json +4 -1
- package/src/astro-service/chart-output-service.ts +155 -0
- package/src/astro-service/date-input.ts +40 -0
- package/src/astro-service/electional-service.ts +395 -0
- package/src/astro-service/natal-service.ts +235 -0
- package/src/astro-service/rising-sign-service.ts +181 -0
- package/src/astro-service/service-types.ts +90 -0
- package/src/astro-service/shared.ts +128 -0
- package/src/astro-service/sky-service.ts +191 -0
- package/src/astro-service/transit-service.ts +507 -0
- package/src/astro-service.ts +177 -1386
- package/src/tool-registry.ts +1 -1
- package/tests/README.md +15 -0
- package/tests/property/electional-service.property.test.ts +67 -0
- package/tests/property/helpers/arbitraries.ts +126 -0
- package/tests/property/helpers/config.ts +52 -0
- package/tests/property/helpers/runtime.ts +12 -0
- package/tests/property/houses.property.test.ts +74 -0
- package/tests/property/rising-sign-service.property.test.ts +255 -0
- package/tests/property/service-transits.property.test.ts +154 -0
- package/tests/property/time-utils.property.test.ts +91 -0
- package/tests/property/transits.property.test.ts +113 -0
- package/tests/unit/astro-service/chart-output-service.test.ts +102 -0
- package/tests/unit/astro-service/electional-service.test.ts +182 -0
- package/tests/unit/astro-service/natal-service.test.ts +126 -0
- package/tests/unit/astro-service/rising-sign-service.test.ts +145 -0
- package/tests/unit/astro-service/sky-service.test.ts +130 -0
- package/tests/unit/astro-service/transit-service.test.ts +312 -0
- package/tests/unit/astro-service.test.ts +136 -781
- package/tests/unit/rising-sign-windows.test.ts +93 -0
- package/tests/unit/tool-registry.test.ts +11 -0
- package/tests/validation/README.md +14 -0
- package/tests/validation/adapters/internal.ts +234 -4
- package/tests/validation/compare/electional.ts +151 -0
- package/tests/validation/compare/rising-sign-windows.ts +347 -0
- package/tests/validation/compare/service-transits.ts +205 -0
- package/tests/validation/fixtures/electional/core.ts +88 -0
- package/tests/validation/fixtures/rising-sign-windows/core.ts +57 -0
- package/tests/validation/fixtures/service-transits/core.ts +89 -0
- package/tests/validation/utils/fixtureTypes.ts +139 -1
- package/tests/validation/validation.spec.ts +82 -0
package/dist/astro-service.js
CHANGED
|
@@ -1,69 +1,26 @@
|
|
|
1
1
|
import { writeFile } from 'node:fs/promises';
|
|
2
|
-
import {
|
|
2
|
+
import { ChartOutputService } from './astro-service/chart-output-service.js';
|
|
3
|
+
import { ElectionalService } from './astro-service/electional-service.js';
|
|
4
|
+
import { NatalService } from './astro-service/natal-service.js';
|
|
5
|
+
import { RisingSignService } from './astro-service/rising-sign-service.js';
|
|
6
|
+
import { resolveReportingTimezone } from './astro-service/shared.js';
|
|
7
|
+
import { SkyService } from './astro-service/sky-service.js';
|
|
8
|
+
import { TransitService } from './astro-service/transit-service.js';
|
|
3
9
|
import { ChartRenderer } from './charts.js';
|
|
4
|
-
import { getDefaultTheme } from './constants.js';
|
|
5
10
|
import { EclipseCalculator } from './eclipses.js';
|
|
6
11
|
import { EphemerisCalculator } from './ephemeris.js';
|
|
7
|
-
import {
|
|
12
|
+
import { formatInTimezone } from './formatter.js';
|
|
8
13
|
import { HouseCalculator } from './houses.js';
|
|
9
14
|
import { RiseSetCalculator } from './riseset.js';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const month = Number(match[2]);
|
|
20
|
-
const day = Number(match[3]);
|
|
21
|
-
if (month < 1 || month > 12) {
|
|
22
|
-
throw new Error(`Invalid month: ${month} (must be 1-12)`);
|
|
23
|
-
}
|
|
24
|
-
if (day < 1 || day > 31) {
|
|
25
|
-
throw new Error(`Invalid day: ${day} (must be 1-31)`);
|
|
26
|
-
}
|
|
27
|
-
try {
|
|
28
|
-
Temporal.PlainDate.from({ year, month, day });
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
throw new Error(`Invalid calendar date: ${dateStr}`);
|
|
32
|
-
}
|
|
33
|
-
return { year, month, day, hour: 12, minute: 0 };
|
|
34
|
-
}
|
|
35
|
-
function parseTimeOnlyInput(timeStr) {
|
|
36
|
-
const match = /^(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(timeStr);
|
|
37
|
-
if (!match) {
|
|
38
|
-
throw new Error(`Invalid time format: expected HH:mm[:ss], got "${timeStr}"`);
|
|
39
|
-
}
|
|
40
|
-
const hour = Number(match[1]);
|
|
41
|
-
const minute = Number(match[2]);
|
|
42
|
-
const second = match[3] === undefined ? 0 : Number(match[3]);
|
|
43
|
-
if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
|
|
44
|
-
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
45
|
-
}
|
|
46
|
-
try {
|
|
47
|
-
Temporal.PlainTime.from({ hour, minute, second });
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
throw new Error(`Invalid clock time: ${timeStr}`);
|
|
51
|
-
}
|
|
52
|
-
return { hour, minute, second };
|
|
53
|
-
}
|
|
54
|
-
const ELECTIONAL_CONTEXT_PLANET_IDS = [
|
|
55
|
-
PLANETS.SUN,
|
|
56
|
-
PLANETS.MOON,
|
|
57
|
-
PLANETS.MERCURY,
|
|
58
|
-
PLANETS.VENUS,
|
|
59
|
-
PLANETS.MARS,
|
|
60
|
-
PLANETS.JUPITER,
|
|
61
|
-
PLANETS.SATURN,
|
|
62
|
-
PLANETS.URANUS,
|
|
63
|
-
PLANETS.NEPTUNE,
|
|
64
|
-
PLANETS.PLUTO,
|
|
65
|
-
];
|
|
66
|
-
const ELECTIONAL_CONTEXT_HOUSE_SYSTEMS = ['P', 'K', 'W', 'R'];
|
|
15
|
+
import { TransitCalculator } from './transits.js';
|
|
16
|
+
export { parseDateOnlyInput } from './astro-service/date-input.js';
|
|
17
|
+
/**
|
|
18
|
+
* Shared service facade used by both the MCP server and the CLI.
|
|
19
|
+
*
|
|
20
|
+
* @remarks
|
|
21
|
+
* Public methods remain the stable orchestration boundary while domain-specific
|
|
22
|
+
* internals can be extracted behind the class without changing callers.
|
|
23
|
+
*/
|
|
67
24
|
export class AstroService {
|
|
68
25
|
ephem;
|
|
69
26
|
transitCalc;
|
|
@@ -72,6 +29,12 @@ export class AstroService {
|
|
|
72
29
|
eclipseCalc;
|
|
73
30
|
chartRenderer;
|
|
74
31
|
mcpStartupDefaults;
|
|
32
|
+
transitService;
|
|
33
|
+
electionalService;
|
|
34
|
+
risingSignService;
|
|
35
|
+
natalService;
|
|
36
|
+
skyService;
|
|
37
|
+
chartOutputService;
|
|
75
38
|
now;
|
|
76
39
|
writeFileFn;
|
|
77
40
|
constructor(deps = {}) {
|
|
@@ -84,1017 +47,174 @@ export class AstroService {
|
|
|
84
47
|
this.mcpStartupDefaults = Object.freeze({ ...(deps.mcpStartupDefaults ?? {}) });
|
|
85
48
|
this.now = deps.now ?? (() => new Date());
|
|
86
49
|
this.writeFileFn = deps.writeFile ?? writeFile;
|
|
50
|
+
this.transitService = new TransitService({
|
|
51
|
+
ephem: this.ephem,
|
|
52
|
+
transitCalc: this.transitCalc,
|
|
53
|
+
houseCalc: this.houseCalc,
|
|
54
|
+
mcpStartupDefaults: this.mcpStartupDefaults,
|
|
55
|
+
now: this.now,
|
|
56
|
+
formatTimestamp: this.formatTimestamp.bind(this),
|
|
57
|
+
});
|
|
58
|
+
this.electionalService = new ElectionalService({
|
|
59
|
+
ephem: this.ephem,
|
|
60
|
+
houseCalc: this.houseCalc,
|
|
61
|
+
});
|
|
62
|
+
this.risingSignService = new RisingSignService({
|
|
63
|
+
ephem: this.ephem,
|
|
64
|
+
houseCalc: this.houseCalc,
|
|
65
|
+
});
|
|
66
|
+
this.natalService = new NatalService({
|
|
67
|
+
ephem: this.ephem,
|
|
68
|
+
houseCalc: this.houseCalc,
|
|
69
|
+
mcpStartupDefaults: this.mcpStartupDefaults,
|
|
70
|
+
isInitialized: this.isInitialized.bind(this),
|
|
71
|
+
});
|
|
72
|
+
this.skyService = new SkyService({
|
|
73
|
+
ephem: this.ephem,
|
|
74
|
+
riseSetCalc: this.riseSetCalc,
|
|
75
|
+
eclipseCalc: this.eclipseCalc,
|
|
76
|
+
mcpStartupDefaults: this.mcpStartupDefaults,
|
|
77
|
+
now: this.now,
|
|
78
|
+
formatTimestamp: this.formatTimestamp.bind(this),
|
|
79
|
+
});
|
|
80
|
+
this.chartOutputService = new ChartOutputService({
|
|
81
|
+
chartRenderer: this.chartRenderer,
|
|
82
|
+
now: this.now,
|
|
83
|
+
writeFile: this.writeFileFn,
|
|
84
|
+
});
|
|
87
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Format user-facing timestamps using the current startup default weekday policy.
|
|
88
|
+
*/
|
|
88
89
|
formatTimestamp(date, timezone) {
|
|
89
90
|
return formatInTimezone(date, timezone, {
|
|
90
91
|
weekday: this.mcpStartupDefaults.weekdayLabels ?? false,
|
|
91
92
|
});
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const shouldCarryToNextSign = roundedDegree >= 30;
|
|
101
|
-
const signIndex = shouldCarryToNextSign
|
|
102
|
-
? (baseSignIndex + 1) % ZODIAC_SIGNS.length
|
|
103
|
-
: baseSignIndex;
|
|
104
|
-
return {
|
|
105
|
-
sign: ZODIAC_SIGNS[signIndex],
|
|
106
|
-
degree: shouldCarryToNextSign ? 0 : roundedDegree,
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
getHouseNumber(longitude, houses) {
|
|
110
|
-
const normalized = this.normalizeLongitude(longitude);
|
|
111
|
-
for (let house = 1; house <= 12; house++) {
|
|
112
|
-
const start = this.normalizeLongitude(houses.cusps[house]);
|
|
113
|
-
const nextHouse = house === 12 ? 1 : house + 1;
|
|
114
|
-
const end = this.normalizeLongitude(houses.cusps[nextHouse]);
|
|
115
|
-
const span = (end - start + 360) % 360;
|
|
116
|
-
const offset = (normalized - start + 360) % 360;
|
|
117
|
-
if (span === 0 || offset === 0 || offset < span) {
|
|
118
|
-
return house;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return 12;
|
|
122
|
-
}
|
|
123
|
-
resolveHouseSystem(natalChart, explicitSystem) {
|
|
124
|
-
return (explicitSystem ||
|
|
125
|
-
natalChart.requestedHouseSystem ||
|
|
126
|
-
this.mcpStartupDefaults.preferredHouseStyle ||
|
|
127
|
-
natalChart.houseSystem ||
|
|
128
|
-
'P');
|
|
129
|
-
}
|
|
130
|
-
resolveTimezones(explicitReportingTimezone, natalTimezone) {
|
|
131
|
-
return {
|
|
132
|
-
calculationTimezone: natalTimezone ?? 'UTC',
|
|
133
|
-
reportingTimezone: this.resolveReportingTimezone(explicitReportingTimezone, natalTimezone),
|
|
134
|
-
};
|
|
135
|
-
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the timezone used for user-facing timestamps and labels.
|
|
96
|
+
*
|
|
97
|
+
* @remarks
|
|
98
|
+
* Explicit per-call timezone wins, then startup defaults, then the natal chart
|
|
99
|
+
* timezone, and finally UTC.
|
|
100
|
+
*/
|
|
136
101
|
resolveReportingTimezone(explicitTimezone, natalTimezone) {
|
|
137
|
-
return
|
|
102
|
+
return resolveReportingTimezone(this.mcpStartupDefaults, explicitTimezone, natalTimezone);
|
|
138
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Initialize the underlying ephemeris engine.
|
|
106
|
+
*/
|
|
139
107
|
async init() {
|
|
140
108
|
await this.ephem.init();
|
|
141
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Report whether the ephemeris engine has been initialized.
|
|
112
|
+
*/
|
|
142
113
|
isInitialized() {
|
|
143
114
|
return !!this.ephem.eph;
|
|
144
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Build and cache the shared natal chart payload used by later workflows.
|
|
118
|
+
*
|
|
119
|
+
* @remarks
|
|
120
|
+
* This preserves the existing natal contract, including polar-latitude house
|
|
121
|
+
* fallback behavior and the current user-facing summary text.
|
|
122
|
+
*/
|
|
145
123
|
setNatalChart(input) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
},
|
|
156
|
-
location: {
|
|
157
|
-
latitude: input.latitude,
|
|
158
|
-
longitude: input.longitude,
|
|
159
|
-
timezone: input.timezone,
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
const birthTimeDisambiguation = input.birth_time_disambiguation ?? 'reject';
|
|
163
|
-
const utcDate = localToUTC(chart.birthDate, chart.location.timezone, birthTimeDisambiguation);
|
|
164
|
-
const utcComponents = utcToLocal(utcDate, 'UTC');
|
|
165
|
-
const jd = this.ephem.dateToJulianDay(utcDate);
|
|
166
|
-
const planetIds = Object.values(PLANETS);
|
|
167
|
-
const positions = this.ephem.getAllPlanets(jd, planetIds);
|
|
168
|
-
const isPolar = Math.abs(chart.location.latitude) > 66;
|
|
169
|
-
let houseSystem = requestedHouseSystem || 'P';
|
|
170
|
-
if (isPolar && houseSystem === 'P') {
|
|
171
|
-
houseSystem = 'W';
|
|
172
|
-
}
|
|
173
|
-
const houses = this.houseCalc.calculateHouses(jd, chart.location.latitude, chart.location.longitude, houseSystem);
|
|
174
|
-
const storedChart = {
|
|
175
|
-
...chart,
|
|
176
|
-
planets: positions,
|
|
177
|
-
julianDay: jd,
|
|
178
|
-
houseSystem: houses.system,
|
|
179
|
-
requestedHouseSystem: requestedHouseSystem ?? undefined,
|
|
180
|
-
utcDateTime: utcComponents,
|
|
181
|
-
};
|
|
182
|
-
const sun = positions.find((p) => p.planet === 'Sun');
|
|
183
|
-
const moon = positions.find((p) => p.planet === 'Moon');
|
|
184
|
-
if (!sun || !moon) {
|
|
185
|
-
throw new Error('Ephemeris failed to compute Sun/Moon positions for natal chart.');
|
|
186
|
-
}
|
|
187
|
-
const formatDegree = (lon) => {
|
|
188
|
-
const sign = ZODIAC_SIGNS[Math.floor(lon / 30)];
|
|
189
|
-
const degree = lon % 30;
|
|
190
|
-
return `${degree.toFixed(0)}° ${sign}`;
|
|
191
|
-
};
|
|
192
|
-
const localTimeStr = `${chart.birthDate.month}/${chart.birthDate.day}/${chart.birthDate.year} ${chart.birthDate.hour}:${String(chart.birthDate.minute).padStart(2, '0')}`;
|
|
193
|
-
const utcTimeStr = `${utcComponents.month}/${utcComponents.day}/${utcComponents.year} ${utcComponents.hour}:${String(utcComponents.minute).padStart(2, '0')} UTC`;
|
|
194
|
-
const systemNames = {
|
|
195
|
-
P: 'Placidus',
|
|
196
|
-
W: 'Whole Sign',
|
|
197
|
-
K: 'Koch',
|
|
198
|
-
E: 'Equal',
|
|
199
|
-
};
|
|
200
|
-
const latDir = chart.location.latitude >= 0 ? 'N' : 'S';
|
|
201
|
-
const lonDir = chart.location.longitude >= 0 ? 'E' : 'W';
|
|
202
|
-
const latAbs = Math.abs(chart.location.latitude);
|
|
203
|
-
const lonAbs = Math.abs(chart.location.longitude);
|
|
204
|
-
const feedback = [
|
|
205
|
-
`Natal chart saved for ${chart.name}`,
|
|
206
|
-
'',
|
|
207
|
-
'Birth Details:',
|
|
208
|
-
`- Local Time: ${localTimeStr} (${chart.location.timezone})`,
|
|
209
|
-
`- UTC Time: ${utcTimeStr}`,
|
|
210
|
-
`- Location: ${latAbs.toFixed(2)}°${latDir}, ${lonAbs.toFixed(2)}°${lonDir}`,
|
|
211
|
-
'',
|
|
212
|
-
'Chart Angles:',
|
|
213
|
-
`- Sun: ${formatDegree(sun.longitude)}`,
|
|
214
|
-
`- Moon: ${formatDegree(moon.longitude)}`,
|
|
215
|
-
`- Ascendant: ${formatDegree(houses.ascendant)}`,
|
|
216
|
-
`- MC: ${formatDegree(houses.mc)}`,
|
|
217
|
-
'',
|
|
218
|
-
`House System: ${systemNames[houses.system] || houses.system}`,
|
|
219
|
-
];
|
|
220
|
-
if (isPolar && houses.system !== houseSystem) {
|
|
221
|
-
feedback.push('', `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Requested ${systemNames[houseSystem]}, using ${systemNames[houses.system]} instead.`);
|
|
222
|
-
}
|
|
223
|
-
else if (isPolar) {
|
|
224
|
-
feedback.push('', `Note: Polar latitude detected (${chart.location.latitude.toFixed(1)}°). Using ${systemNames[houses.system]} house system.`);
|
|
225
|
-
}
|
|
226
|
-
const structuredData = {
|
|
227
|
-
name: chart.name,
|
|
228
|
-
birthTime: {
|
|
229
|
-
local: localTimeStr,
|
|
230
|
-
utc: utcTimeStr,
|
|
231
|
-
timezone: chart.location.timezone,
|
|
232
|
-
},
|
|
233
|
-
location: {
|
|
234
|
-
latitude: chart.location.latitude,
|
|
235
|
-
longitude: chart.location.longitude,
|
|
236
|
-
},
|
|
237
|
-
julianDay: jd,
|
|
238
|
-
requestedHouseSystem,
|
|
239
|
-
resolvedHouseSystem: houses.system,
|
|
240
|
-
angles: {
|
|
241
|
-
sun: formatDegree(sun.longitude),
|
|
242
|
-
moon: formatDegree(moon.longitude),
|
|
243
|
-
ascendant: formatDegree(houses.ascendant),
|
|
244
|
-
mc: formatDegree(houses.mc),
|
|
245
|
-
},
|
|
246
|
-
isPolar,
|
|
247
|
-
};
|
|
248
|
-
return {
|
|
249
|
-
chart: storedChart,
|
|
250
|
-
data: structuredData,
|
|
251
|
-
text: feedback.join('\n'),
|
|
252
|
-
};
|
|
253
|
-
}
|
|
124
|
+
return this.natalService.setNatalChart(input);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Calculate natal transits while preserving the public service contract.
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* Transit day interpretation uses the natal chart timezone for calculation and
|
|
131
|
+
* may use a different reporting timezone for labels when startup defaults are set.
|
|
132
|
+
*/
|
|
254
133
|
getTransits(natalChart, input = {}) {
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
throw new Error('days_ahead must be a finite number >= 0');
|
|
265
|
-
}
|
|
266
|
-
if (!Number.isFinite(maxOrb) || maxOrb < 0) {
|
|
267
|
-
throw new Error('max_orb must be a finite number >= 0');
|
|
268
|
-
}
|
|
269
|
-
if (requestedMode !== undefined &&
|
|
270
|
-
requestedMode !== 'snapshot' &&
|
|
271
|
-
requestedMode !== 'best_hit' &&
|
|
272
|
-
requestedMode !== 'forecast') {
|
|
273
|
-
throw new Error('mode must be one of: snapshot, best_hit, forecast');
|
|
274
|
-
}
|
|
275
|
-
if (!natalChart.julianDay) {
|
|
276
|
-
throw new Error('Natal chart is missing julianDay. Re-run set_natal_chart to fix.');
|
|
277
|
-
}
|
|
278
|
-
const mode = requestedMode ?? (daysAhead === 0 ? 'snapshot' : 'best_hit');
|
|
279
|
-
const modeSource = requestedMode === undefined ? 'legacy_default' : 'explicit';
|
|
280
|
-
let transitingPlanetIds = [];
|
|
281
|
-
if (categories.includes('all')) {
|
|
282
|
-
transitingPlanetIds = Object.values(PLANETS);
|
|
283
|
-
}
|
|
284
|
-
else {
|
|
285
|
-
if (categories.includes('moon'))
|
|
286
|
-
transitingPlanetIds.push(PLANETS.MOON);
|
|
287
|
-
if (categories.includes('personal')) {
|
|
288
|
-
transitingPlanetIds.push(...PERSONAL_PLANETS.filter((p) => !transitingPlanetIds.includes(p)));
|
|
289
|
-
}
|
|
290
|
-
if (categories.includes('outer')) {
|
|
291
|
-
transitingPlanetIds.push(...OUTER_PLANETS.filter((p) => !transitingPlanetIds.includes(p)));
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
const { calculationTimezone, reportingTimezone } = this.resolveTimezones(undefined, natalChart.location.timezone);
|
|
295
|
-
let targetDate;
|
|
296
|
-
if (dateStr) {
|
|
297
|
-
const parsed = parseDateOnlyInput(dateStr);
|
|
298
|
-
targetDate = localToUTC(parsed, calculationTimezone);
|
|
299
|
-
}
|
|
300
|
-
else {
|
|
301
|
-
const now = this.now();
|
|
302
|
-
const localNow = utcToLocal(now, calculationTimezone);
|
|
303
|
-
const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
|
|
304
|
-
targetDate = localToUTC(localNoon, calculationTimezone);
|
|
305
|
-
}
|
|
306
|
-
const allTransits = [];
|
|
307
|
-
const transitsByDay = new Map();
|
|
308
|
-
const transitContext = new WeakMap();
|
|
309
|
-
const startLocal = utcToLocal(targetDate, calculationTimezone);
|
|
310
|
-
const effectiveDaysAhead = mode === 'snapshot' ? 0 : daysAhead;
|
|
311
|
-
for (let day = 0; day <= effectiveDaysAhead; day++) {
|
|
312
|
-
const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
|
|
313
|
-
const jd = this.ephem.dateToJulianDay(dayUTC);
|
|
314
|
-
const transitingPlanets = this.ephem.getAllPlanets(jd, transitingPlanetIds);
|
|
315
|
-
const transits = this.transitCalc.findTransits(transitingPlanets, natalChart.planets || [], jd);
|
|
316
|
-
for (const transit of transits) {
|
|
317
|
-
transitContext.set(transit, { julianDay: jd });
|
|
318
|
-
}
|
|
319
|
-
allTransits.push(...transits);
|
|
320
|
-
const dayLocal = utcToLocal(dayUTC, reportingTimezone);
|
|
321
|
-
const dayLabel = `${dayLocal.year}-${String(dayLocal.month).padStart(2, '0')}-${String(dayLocal.day).padStart(2, '0')}`;
|
|
322
|
-
transitsByDay.set(dayLabel, transits);
|
|
323
|
-
}
|
|
324
|
-
const filterTransits = (transits) => {
|
|
325
|
-
let filtered = transits.filter((t) => t.orb <= maxOrb);
|
|
326
|
-
if (exactOnly)
|
|
327
|
-
filtered = filtered.filter((t) => t.exactTime !== undefined);
|
|
328
|
-
if (applyingOnly)
|
|
329
|
-
filtered = filtered.filter((t) => t.isApplying);
|
|
330
|
-
filtered.sort((a, b) => a.orb - b.orb);
|
|
331
|
-
return filtered;
|
|
332
|
-
};
|
|
333
|
-
const chartHouseSystem = this.resolveHouseSystem(natalChart);
|
|
334
|
-
const natalHouses = this.houseCalc.calculateHouses(natalChart.julianDay, natalChart.location.latitude, natalChart.location.longitude, chartHouseSystem);
|
|
335
|
-
const transitHouseCache = new Map();
|
|
336
|
-
const planetIdsByName = new Map(Object.entries(PLANET_NAMES).map(([id, name]) => [name, Number(id)]));
|
|
337
|
-
const getTransitHouses = (julianDay) => {
|
|
338
|
-
const cached = transitHouseCache.get(julianDay);
|
|
339
|
-
if (cached) {
|
|
340
|
-
return cached;
|
|
341
|
-
}
|
|
342
|
-
const houses = this.houseCalc.calculateHouses(julianDay, natalChart.location.latitude, natalChart.location.longitude, chartHouseSystem);
|
|
343
|
-
transitHouseCache.set(julianDay, houses);
|
|
344
|
-
return houses;
|
|
345
|
-
};
|
|
346
|
-
const serializeTransit = (t) => {
|
|
347
|
-
const transitPlacement = this.getSignAndDegree(t.transitLongitude);
|
|
348
|
-
const natalPlacement = this.getSignAndDegree(t.natalLongitude);
|
|
349
|
-
const context = transitContext.get(t);
|
|
350
|
-
const transitHouseJulianDay = t.exactTime
|
|
351
|
-
? this.ephem.dateToJulianDay(t.exactTime)
|
|
352
|
-
: (context?.julianDay ?? this.ephem.dateToJulianDay(targetDate));
|
|
353
|
-
const transitHouses = getTransitHouses(transitHouseJulianDay);
|
|
354
|
-
const exactTransitLongitude = t.exactTime && planetIdsByName.has(t.transitingPlanet)
|
|
355
|
-
? this.ephem.getPlanetPosition(planetIdsByName.get(t.transitingPlanet), transitHouseJulianDay).longitude
|
|
356
|
-
: t.transitLongitude;
|
|
357
|
-
return {
|
|
358
|
-
transitingPlanet: t.transitingPlanet,
|
|
359
|
-
aspect: t.aspect,
|
|
360
|
-
natalPlanet: t.natalPlanet,
|
|
361
|
-
orb: Number.parseFloat(t.orb.toFixed(2)),
|
|
362
|
-
isApplying: t.isApplying,
|
|
363
|
-
exactTimeStatus: t.exactTimeStatus,
|
|
364
|
-
exactTime: t.exactTime?.toISOString(),
|
|
365
|
-
transitLongitude: t.transitLongitude,
|
|
366
|
-
natalLongitude: t.natalLongitude,
|
|
367
|
-
transitSign: transitPlacement.sign,
|
|
368
|
-
transitDegree: transitPlacement.degree,
|
|
369
|
-
transitHouse: this.getHouseNumber(exactTransitLongitude, transitHouses),
|
|
370
|
-
natalSign: natalPlacement.sign,
|
|
371
|
-
natalDegree: natalPlacement.degree,
|
|
372
|
-
natalHouse: this.getHouseNumber(t.natalLongitude, natalHouses),
|
|
373
|
-
};
|
|
374
|
-
};
|
|
375
|
-
const filteredTransits = mode === 'forecast'
|
|
376
|
-
? filterTransits(deduplicateTransits(allTransits))
|
|
377
|
-
: filterTransits(deduplicateTransits(allTransits));
|
|
378
|
-
const localDate = utcToLocal(targetDate, reportingTimezone);
|
|
379
|
-
const dateLabel = `${localDate.year}-${String(localDate.month).padStart(2, '0')}-${String(localDate.day).padStart(2, '0')}`;
|
|
380
|
-
const endLocal = utcToLocal(addLocalDays(startLocal, calculationTimezone, effectiveDaysAhead), reportingTimezone);
|
|
381
|
-
const windowEndLabel = `${endLocal.year}-${String(endLocal.month).padStart(2, '0')}-${String(endLocal.day).padStart(2, '0')}`;
|
|
382
|
-
const structuredData = {
|
|
383
|
-
date: dateLabel,
|
|
384
|
-
timezone: reportingTimezone,
|
|
385
|
-
calculation_timezone: calculationTimezone,
|
|
386
|
-
reporting_timezone: reportingTimezone,
|
|
387
|
-
transits: filteredTransits.map(serializeTransit),
|
|
388
|
-
};
|
|
389
|
-
const metadata = {
|
|
390
|
-
mode,
|
|
391
|
-
mode_source: modeSource,
|
|
392
|
-
days_ahead: effectiveDaysAhead,
|
|
393
|
-
window_start: dateLabel,
|
|
394
|
-
window_end: windowEndLabel,
|
|
395
|
-
};
|
|
396
|
-
let responseData = structuredData;
|
|
397
|
-
let mundaneText = '';
|
|
398
|
-
if (mode === 'forecast') {
|
|
399
|
-
const forecastDays = Array.from(transitsByDay.entries())
|
|
400
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
401
|
-
.map(([dayDate, dayTransits]) => ({
|
|
402
|
-
date: dayDate,
|
|
403
|
-
transits: filterTransits(deduplicateTransits(dayTransits)).map(serializeTransit),
|
|
404
|
-
}));
|
|
405
|
-
responseData = {
|
|
406
|
-
...metadata,
|
|
407
|
-
timezone: reportingTimezone,
|
|
408
|
-
calculation_timezone: calculationTimezone,
|
|
409
|
-
reporting_timezone: reportingTimezone,
|
|
410
|
-
forecast: forecastDays,
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
else {
|
|
414
|
-
responseData = {
|
|
415
|
-
...structuredData,
|
|
416
|
-
...metadata,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
if (includeMundane) {
|
|
420
|
-
const mundaneDays = [];
|
|
421
|
-
for (let day = 0; day <= daysAhead; day++) {
|
|
422
|
-
const dayUTC = addLocalDays(startLocal, calculationTimezone, day);
|
|
423
|
-
mundaneDays.push(this.getMundaneDay(dayUTC, reportingTimezone, transitingPlanetIds));
|
|
424
|
-
}
|
|
425
|
-
const [anchorMundane] = mundaneDays;
|
|
426
|
-
const mundaneData = {
|
|
427
|
-
date: anchorMundane.date,
|
|
428
|
-
timezone: anchorMundane.timezone,
|
|
429
|
-
positions: anchorMundane.positions,
|
|
430
|
-
aspects: anchorMundane.aspects,
|
|
431
|
-
days: mundaneDays,
|
|
432
|
-
};
|
|
433
|
-
responseData = { transits: responseData, mundane: mundaneData };
|
|
434
|
-
mundaneText = `\n\nCurrent Planetary Positions:\n\n${anchorMundane.positions
|
|
435
|
-
.map((p) => `${p.planet}: ${p.degree.toFixed(1)}° ${p.sign} (${p.isRetrograde ? 'Rx' : 'Direct'})`)
|
|
436
|
-
.join('\n')}`;
|
|
437
|
-
if (mode === 'forecast') {
|
|
438
|
-
mundaneText +=
|
|
439
|
-
'\n\nNote: mundane positions remain anchored to the forecast start date in this mode.';
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
const formatHumanTransit = (t) => {
|
|
443
|
-
const exactStr = t.exactTime
|
|
444
|
-
? ` - Exact: ${this.formatTimestamp(t.exactTime, reportingTimezone)}`
|
|
445
|
-
: '';
|
|
446
|
-
const applyStr = t.isApplying ? '(applying)' : '(separating)';
|
|
447
|
-
return `${t.transitingPlanet} ${t.aspect} ${t.natalPlanet}: ${t.orb.toFixed(2)}° orb ${applyStr}${exactStr}`;
|
|
448
|
-
};
|
|
449
|
-
const rangeStr = effectiveDaysAhead > 0 ? ` (next ${effectiveDaysAhead + 1} days)` : '';
|
|
450
|
-
let transitHeader;
|
|
451
|
-
if (mode === 'forecast') {
|
|
452
|
-
const forecastLines = Array.from(transitsByDay.entries())
|
|
453
|
-
.sort(([a], [b]) => a.localeCompare(b))
|
|
454
|
-
.map(([dayDate, dayTransits]) => {
|
|
455
|
-
const dedupedDay = filterTransits(deduplicateTransits(dayTransits));
|
|
456
|
-
const lines = dedupedDay.length === 0
|
|
457
|
-
? 'No transits found matching the specified criteria.'
|
|
458
|
-
: dedupedDay.map(formatHumanTransit).join('\n');
|
|
459
|
-
return `${dayDate}:\n${lines}`;
|
|
460
|
-
})
|
|
461
|
-
.join('\n\n');
|
|
462
|
-
transitHeader = `Forecast transits${rangeStr}:\n\n${forecastLines}`;
|
|
463
|
-
}
|
|
464
|
-
else {
|
|
465
|
-
const humanLines = filteredTransits.map(formatHumanTransit).join('\n');
|
|
466
|
-
const modeLabel = mode === 'snapshot' ? 'Transit snapshot' : 'Best-hit transits';
|
|
467
|
-
transitHeader =
|
|
468
|
-
filteredTransits.length > 0
|
|
469
|
-
? `${modeLabel}${rangeStr}:\n\n${humanLines}`
|
|
470
|
-
: 'No transits found matching the specified criteria.';
|
|
471
|
-
}
|
|
472
|
-
return {
|
|
473
|
-
data: responseData,
|
|
474
|
-
text: transitHeader + mundaneText,
|
|
475
|
-
};
|
|
476
|
-
}
|
|
477
|
-
getMundaneWeather(aspects) {
|
|
478
|
-
const supportiveAspects = new Set(['conjunction', 'trine', 'sextile']);
|
|
479
|
-
const challengingAspects = new Set(['square', 'opposition']);
|
|
480
|
-
return {
|
|
481
|
-
supportive: aspects.filter((a) => supportiveAspects.has(a.aspect)).map((a) => a.id),
|
|
482
|
-
challenging: aspects.filter((a) => challengingAspects.has(a.aspect)).map((a) => a.id),
|
|
483
|
-
};
|
|
484
|
-
}
|
|
485
|
-
getMundaneAspects(date, positions) {
|
|
486
|
-
const aspects = [];
|
|
487
|
-
for (let i = 0; i < positions.length; i++) {
|
|
488
|
-
for (let j = i + 1; j < positions.length; j++) {
|
|
489
|
-
const planetA = positions[i];
|
|
490
|
-
const planetB = positions[j];
|
|
491
|
-
const angle = this.ephem.calculateAspectAngle(planetA.longitude, planetB.longitude);
|
|
492
|
-
for (const aspect of ASPECTS) {
|
|
493
|
-
const orb = Math.abs(angle - aspect.angle);
|
|
494
|
-
if (orb > aspect.orb) {
|
|
495
|
-
continue;
|
|
496
|
-
}
|
|
497
|
-
const futureLongitudeA = (planetA.longitude + planetA.speed * 0.1 + 360) % 360;
|
|
498
|
-
const futureLongitudeB = (planetB.longitude + planetB.speed * 0.1 + 360) % 360;
|
|
499
|
-
const futureAngle = this.ephem.calculateAspectAngle(futureLongitudeA, futureLongitudeB);
|
|
500
|
-
const futureOrb = Math.abs(futureAngle - aspect.angle);
|
|
501
|
-
aspects.push({
|
|
502
|
-
id: `${date}:${planetA.planet}-${aspect.name}-${planetB.planet}`,
|
|
503
|
-
planetA: planetA.planet,
|
|
504
|
-
planetB: planetB.planet,
|
|
505
|
-
aspect: aspect.name,
|
|
506
|
-
orb: Number.parseFloat(orb.toFixed(2)),
|
|
507
|
-
isApplying: futureOrb < orb,
|
|
508
|
-
longitudeA: planetA.longitude,
|
|
509
|
-
longitudeB: planetB.longitude,
|
|
510
|
-
});
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
return aspects.sort((a, b) => a.orb - b.orb ||
|
|
515
|
-
a.planetA.localeCompare(b.planetA) ||
|
|
516
|
-
a.planetB.localeCompare(b.planetB) ||
|
|
517
|
-
a.aspect.localeCompare(b.aspect));
|
|
518
|
-
}
|
|
519
|
-
getMundaneDay(dayUTC, timezone, transitingPlanetIds) {
|
|
520
|
-
const localDay = utcToLocal(dayUTC, timezone);
|
|
521
|
-
const dateLabel = `${localDay.year}-${String(localDay.month).padStart(2, '0')}-${String(localDay.day).padStart(2, '0')}`;
|
|
522
|
-
const currentJD = this.ephem.dateToJulianDay(dayUTC);
|
|
523
|
-
const positions = this.ephem.getAllPlanets(currentJD, transitingPlanetIds);
|
|
524
|
-
const aspects = this.getMundaneAspects(dateLabel, positions);
|
|
525
|
-
return {
|
|
526
|
-
date: dateLabel,
|
|
527
|
-
timezone,
|
|
528
|
-
positions,
|
|
529
|
-
aspects,
|
|
530
|
-
weather: this.getMundaneWeather(aspects),
|
|
531
|
-
};
|
|
532
|
-
}
|
|
134
|
+
return this.transitService.getTransits(natalChart, input);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Produce deterministic electional context for a single local instant.
|
|
138
|
+
*
|
|
139
|
+
* @remarks
|
|
140
|
+
* Electional local times keep strict DST rejection semantics for ambiguous or
|
|
141
|
+
* nonexistent wall-clock instants.
|
|
142
|
+
*/
|
|
533
143
|
getElectionalContext(input) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
}
|
|
544
|
-
const includeRulerBasics = input.include_ruler_basics ?? false;
|
|
545
|
-
const includePlanetaryApplications = input.include_planetary_applications ?? true;
|
|
546
|
-
const orbDegrees = input.orb_degrees ?? 3;
|
|
547
|
-
if (!Number.isFinite(orbDegrees) || orbDegrees < 0.1 || orbDegrees > 10) {
|
|
548
|
-
throw new Error(`Invalid orb_degrees: ${orbDegrees} (must be between 0.1 and 10)`);
|
|
549
|
-
}
|
|
550
|
-
const parsedDate = parseDateOnlyInput(input.date);
|
|
551
|
-
const parsedTime = parseTimeOnlyInput(input.time);
|
|
552
|
-
let instantUtc;
|
|
553
|
-
try {
|
|
554
|
-
instantUtc = localToUTC({
|
|
555
|
-
year: parsedDate.year,
|
|
556
|
-
month: parsedDate.month,
|
|
557
|
-
day: parsedDate.day,
|
|
558
|
-
hour: parsedTime.hour,
|
|
559
|
-
minute: parsedTime.minute,
|
|
560
|
-
second: parsedTime.second,
|
|
561
|
-
}, input.timezone, 'reject');
|
|
562
|
-
}
|
|
563
|
-
catch (error) {
|
|
564
|
-
if (error instanceof RangeError) {
|
|
565
|
-
throw new Error(`Invalid local electional time: ${input.date} ${input.time} in ${input.timezone} is ambiguous or nonexistent due to a DST transition.`);
|
|
566
|
-
}
|
|
567
|
-
throw error;
|
|
568
|
-
}
|
|
569
|
-
const jdUt = this.ephem.dateToJulianDay(instantUtc);
|
|
570
|
-
const houses = this.houseCalc.calculateHouses(jdUt, input.latitude, input.longitude, houseSystem);
|
|
571
|
-
const positions = this.ephem.getAllPlanets(jdUt, ELECTIONAL_CONTEXT_PLANET_IDS);
|
|
572
|
-
const sun = positions.find((position) => position.planet === 'Sun');
|
|
573
|
-
const moon = positions.find((position) => position.planet === 'Moon');
|
|
574
|
-
if (!sun || !moon) {
|
|
575
|
-
throw new Error('Ephemeris failed to compute Sun/Moon positions for electional context.');
|
|
576
|
-
}
|
|
577
|
-
const sunHorizontal = this.ephem.getHorizontalCoordinates(jdUt, sun, input.longitude, input.latitude);
|
|
578
|
-
const sunAltitudeDegrees = Number.parseFloat(sunHorizontal.trueAltitude.toFixed(2));
|
|
579
|
-
const isDayChart = sunAltitudeDegrees >= 0;
|
|
580
|
-
const applyingAspects = includePlanetaryApplications
|
|
581
|
-
? this.getElectionalApplyingAspects(positions, orbDegrees)
|
|
582
|
-
: undefined;
|
|
583
|
-
const moonApplyingAspects = applyingAspects?.filter((aspect) => aspect.from_body === 'Moon' || aspect.to_body === 'Moon');
|
|
584
|
-
const phaseAngle = Number.parseFloat(((((moon.longitude - sun.longitude) % 360) + 360) % 360).toFixed(2));
|
|
585
|
-
const warnings = [];
|
|
586
|
-
if (Math.abs(sunAltitudeDegrees) < 0.5) {
|
|
587
|
-
warnings.push('Sun is near the horizon; day/night classification is close to the boundary.');
|
|
588
|
-
}
|
|
589
|
-
warnings.push('Moon void-of-course is deferred in this slice and returns null.');
|
|
590
|
-
if (houses.system !== houseSystem) {
|
|
591
|
-
warnings.push(`House calculation fell back from ${houseSystem} to ${houses.system} for this location.`);
|
|
592
|
-
}
|
|
593
|
-
const ascLongitude = ((houses.ascendant % 360) + 360) % 360;
|
|
594
|
-
const ascSign = ZODIAC_SIGNS[Math.floor(ascLongitude / 30)];
|
|
595
|
-
const response = {
|
|
596
|
-
input: {
|
|
597
|
-
date: input.date,
|
|
598
|
-
time: input.time,
|
|
599
|
-
timezone: input.timezone,
|
|
600
|
-
latitude: input.latitude,
|
|
601
|
-
longitude: input.longitude,
|
|
602
|
-
house_system: houses.system,
|
|
603
|
-
instant_utc: instantUtc.toISOString(),
|
|
604
|
-
jd_ut: Number.parseFloat(jdUt.toFixed(8)),
|
|
605
|
-
},
|
|
606
|
-
ascendant: {
|
|
607
|
-
longitude: Number.parseFloat(ascLongitude.toFixed(4)),
|
|
608
|
-
sign: ascSign,
|
|
609
|
-
degree_in_sign: Number.parseFloat((ascLongitude % 30).toFixed(4)),
|
|
610
|
-
},
|
|
611
|
-
sect: {
|
|
612
|
-
is_day_chart: isDayChart,
|
|
613
|
-
sun_altitude_degrees: sunAltitudeDegrees,
|
|
614
|
-
classification: isDayChart ? 'day' : 'night',
|
|
615
|
-
},
|
|
616
|
-
moon: {
|
|
617
|
-
longitude: Number.parseFloat(moon.longitude.toFixed(4)),
|
|
618
|
-
sign: moon.sign,
|
|
619
|
-
phase_angle: phaseAngle,
|
|
620
|
-
phase_name: this.getElectionalPhaseName(phaseAngle),
|
|
621
|
-
is_void_of_course: null,
|
|
622
|
-
...(moonApplyingAspects !== undefined ? { applying_aspects: moonApplyingAspects } : {}),
|
|
623
|
-
},
|
|
624
|
-
meta: {
|
|
625
|
-
deterministic: true,
|
|
626
|
-
requires_natal: false,
|
|
627
|
-
warnings,
|
|
628
|
-
deferred_features: [
|
|
629
|
-
'robust_void_of_course',
|
|
630
|
-
'detailed_ruler_condition',
|
|
631
|
-
'house_context',
|
|
632
|
-
'natal_overlays',
|
|
633
|
-
],
|
|
634
|
-
},
|
|
635
|
-
};
|
|
636
|
-
if (applyingAspects) {
|
|
637
|
-
response.applying_aspects = applyingAspects;
|
|
638
|
-
}
|
|
639
|
-
if (includeRulerBasics) {
|
|
640
|
-
const rulerBody = this.getTraditionalSignRuler(ascSign);
|
|
641
|
-
const rulerPosition = positions.find((position) => position.planet === rulerBody);
|
|
642
|
-
if (!rulerPosition) {
|
|
643
|
-
throw new Error(`Ephemeris failed to compute ASC ruler position for ${rulerBody}.`);
|
|
644
|
-
}
|
|
645
|
-
response.ruler_basics = {
|
|
646
|
-
asc_sign_ruler: {
|
|
647
|
-
body: rulerBody,
|
|
648
|
-
longitude: Number.parseFloat(rulerPosition.longitude.toFixed(4)),
|
|
649
|
-
sign: rulerPosition.sign,
|
|
650
|
-
speed: Number.parseFloat(rulerPosition.speed.toFixed(6)),
|
|
651
|
-
is_retrograde: rulerPosition.isRetrograde,
|
|
652
|
-
},
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
const humanText = [
|
|
656
|
-
`Electional context for ${input.date} ${input.time} (${input.timezone})`,
|
|
657
|
-
'',
|
|
658
|
-
`Ascendant: ${response.ascendant.degree_in_sign.toFixed(2)}° ${response.ascendant.sign}`,
|
|
659
|
-
`Sect: ${response.sect.classification} (${response.sect.sun_altitude_degrees.toFixed(2)}° Sun altitude)`,
|
|
660
|
-
`Moon: ${response.moon.phase_name} in ${response.moon.sign} (${response.moon.phase_angle.toFixed(2)}° phase angle)`,
|
|
661
|
-
];
|
|
662
|
-
if (includePlanetaryApplications) {
|
|
663
|
-
const topLevelAspectText = applyingAspects && applyingAspects.length > 0
|
|
664
|
-
? applyingAspects
|
|
665
|
-
.slice(0, 5)
|
|
666
|
-
.map((aspect) => `${aspect.from_body} ${aspect.aspect} ${aspect.to_body} (${aspect.orb.toFixed(2)}°)`)
|
|
667
|
-
.join('\n')
|
|
668
|
-
: 'No applying aspects found within the configured orb.';
|
|
669
|
-
humanText.push('', 'Applying Aspects:', topLevelAspectText);
|
|
670
|
-
}
|
|
671
|
-
if (response.ruler_basics) {
|
|
672
|
-
humanText.push('', `ASC Ruler: ${response.ruler_basics.asc_sign_ruler.body} in ${response.ruler_basics.asc_sign_ruler.sign} (${response.ruler_basics.asc_sign_ruler.longitude.toFixed(2)}°)`);
|
|
673
|
-
}
|
|
674
|
-
if (warnings.length > 0) {
|
|
675
|
-
humanText.push('', `Warnings: ${warnings.join(' ')}`);
|
|
676
|
-
}
|
|
677
|
-
return {
|
|
678
|
-
data: response,
|
|
679
|
-
text: humanText.join('\n'),
|
|
680
|
-
};
|
|
681
|
-
}
|
|
144
|
+
return this.electionalService.getElectionalContext(input);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Calculate house cusps and angles for a natal chart.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* House-system resolution still respects explicit per-call input, then stored
|
|
151
|
+
* chart preference, then startup defaults.
|
|
152
|
+
*/
|
|
682
153
|
getHouses(natalChart, input = {}) {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return `House ${i + 1}: ${(deg % 30).toFixed(2)}° ${sign}`;
|
|
693
|
-
})
|
|
694
|
-
.join('\n');
|
|
695
|
-
const humanText = `Houses (${houses.system}):\nAsc: ${houses.ascendant.toFixed(2)}° | MC: ${houses.mc.toFixed(2)}°\n\n${humanLines}`;
|
|
696
|
-
return {
|
|
697
|
-
data: houses,
|
|
698
|
-
text: humanText,
|
|
699
|
-
};
|
|
700
|
-
}
|
|
154
|
+
return this.natalService.getHouses(natalChart, input);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Find rising-sign windows across a calendar day at a specific location.
|
|
158
|
+
*
|
|
159
|
+
* @remarks
|
|
160
|
+
* `exact` mode refines sign boundaries more aggressively; `approximate` mode
|
|
161
|
+
* keeps the cheaper bucketed scan behavior.
|
|
162
|
+
*/
|
|
701
163
|
getRisingSignWindows(input) {
|
|
702
|
-
|
|
703
|
-
if (mode !== 'approximate' && mode !== 'exact') {
|
|
704
|
-
throw new Error(`Invalid mode: ${mode} (must be approximate or exact)`);
|
|
705
|
-
}
|
|
706
|
-
if (input.latitude < -90 || input.latitude > 90) {
|
|
707
|
-
throw new Error(`Invalid latitude: ${input.latitude} (must be between -90 and 90)`);
|
|
708
|
-
}
|
|
709
|
-
if (input.longitude < -180 || input.longitude > 180) {
|
|
710
|
-
throw new Error(`Invalid longitude: ${input.longitude} (must be between -180 and 180)`);
|
|
711
|
-
}
|
|
712
|
-
const parsed = parseDateOnlyInput(input.date);
|
|
713
|
-
try {
|
|
714
|
-
utcToLocal(new Date(), input.timezone);
|
|
715
|
-
}
|
|
716
|
-
catch {
|
|
717
|
-
throw new Error(`Invalid timezone: ${input.timezone}`);
|
|
718
|
-
}
|
|
719
|
-
const dayStartLocal = {
|
|
720
|
-
year: parsed.year,
|
|
721
|
-
month: parsed.month,
|
|
722
|
-
day: parsed.day,
|
|
723
|
-
hour: 0,
|
|
724
|
-
minute: 0,
|
|
725
|
-
second: 0,
|
|
726
|
-
};
|
|
727
|
-
const dayStartUtc = localToUTC(dayStartLocal, input.timezone);
|
|
728
|
-
const dayEndUtc = addLocalDays(dayStartLocal, input.timezone, 1);
|
|
729
|
-
const getAscSign = (date) => {
|
|
730
|
-
const jd = this.ephem.dateToJulianDay(date);
|
|
731
|
-
const houses = this.houseCalc.calculateHouses(jd, input.latitude, input.longitude, 'P');
|
|
732
|
-
const normalized = ((houses.ascendant % 360) + 360) % 360;
|
|
733
|
-
return { sign: ZODIAC_SIGNS[Math.floor(normalized / 30)], longitude: normalized };
|
|
734
|
-
};
|
|
735
|
-
const refineBoundary = (left, right) => {
|
|
736
|
-
const leftSign = getAscSign(left).sign;
|
|
737
|
-
let lo = left;
|
|
738
|
-
let hi = right;
|
|
739
|
-
for (let i = 0; i < 25; i++) {
|
|
740
|
-
const mid = new Date((lo.getTime() + hi.getTime()) / 2);
|
|
741
|
-
const midSign = getAscSign(mid).sign;
|
|
742
|
-
if (midSign === leftSign) {
|
|
743
|
-
lo = mid;
|
|
744
|
-
}
|
|
745
|
-
else {
|
|
746
|
-
hi = mid;
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
return hi;
|
|
750
|
-
};
|
|
751
|
-
const findSignTransitionsInBucket = (start, end, probeStepMs) => {
|
|
752
|
-
const boundaries = [];
|
|
753
|
-
let probeCursor = start;
|
|
754
|
-
let currentSign = getAscSign(probeCursor).sign;
|
|
755
|
-
while (probeCursor < end) {
|
|
756
|
-
const probeNext = new Date(Math.min(probeCursor.getTime() + probeStepMs, end.getTime()));
|
|
757
|
-
const nextSign = getAscSign(probeNext).sign;
|
|
758
|
-
if (nextSign !== currentSign) {
|
|
759
|
-
boundaries.push(mode === 'exact' ? refineBoundary(probeCursor, probeNext) : probeNext);
|
|
760
|
-
}
|
|
761
|
-
probeCursor = probeNext;
|
|
762
|
-
currentSign = nextSign;
|
|
763
|
-
}
|
|
764
|
-
return boundaries;
|
|
765
|
-
};
|
|
766
|
-
const stepMs = mode === 'exact' ? 60 * 60 * 1000 : 2 * 60 * 60 * 1000;
|
|
767
|
-
const probeStepMs = mode === 'exact' ? 5 * 60 * 1000 : 30 * 60 * 1000;
|
|
768
|
-
const boundaries = [dayStartUtc];
|
|
769
|
-
let cursor = dayStartUtc;
|
|
770
|
-
while (cursor < dayEndUtc) {
|
|
771
|
-
const next = new Date(Math.min(cursor.getTime() + stepMs, dayEndUtc.getTime()));
|
|
772
|
-
boundaries.push(...findSignTransitionsInBucket(cursor, next, probeStepMs));
|
|
773
|
-
cursor = next;
|
|
774
|
-
}
|
|
775
|
-
boundaries.push(dayEndUtc);
|
|
776
|
-
const windows = boundaries.slice(0, -1).map((start, i) => {
|
|
777
|
-
const end = boundaries[i + 1];
|
|
778
|
-
const sample = new Date((start.getTime() + end.getTime()) / 2);
|
|
779
|
-
const sign = getAscSign(sample).sign;
|
|
780
|
-
return {
|
|
781
|
-
sign,
|
|
782
|
-
start: formatLocalTimestampWithOffset(start, input.timezone),
|
|
783
|
-
end: formatLocalTimestampWithOffset(end, input.timezone),
|
|
784
|
-
durationMinutes: Math.round((end.getTime() - start.getTime()) / 60000),
|
|
785
|
-
};
|
|
786
|
-
});
|
|
787
|
-
const structuredData = {
|
|
788
|
-
date: input.date,
|
|
789
|
-
timezone: input.timezone,
|
|
790
|
-
location: {
|
|
791
|
-
latitude: input.latitude,
|
|
792
|
-
longitude: input.longitude,
|
|
793
|
-
},
|
|
794
|
-
mode,
|
|
795
|
-
windows,
|
|
796
|
-
};
|
|
797
|
-
const humanText = `Rising Sign Windows (${input.date}, ${input.timezone}, ${mode}):\n\n${windows
|
|
798
|
-
.map((window) => `${window.sign}: ${formatInTimezone(new Date(window.start), input.timezone)} → ${formatInTimezone(new Date(window.end), input.timezone)}`)
|
|
799
|
-
.join('\n')}`;
|
|
800
|
-
return {
|
|
801
|
-
data: structuredData,
|
|
802
|
-
text: humanText,
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
getElectionalApplyingAspects(positions, orbDegrees) {
|
|
806
|
-
const aspects = [];
|
|
807
|
-
for (let i = 0; i < positions.length; i++) {
|
|
808
|
-
for (let j = i + 1; j < positions.length; j++) {
|
|
809
|
-
const from = positions[i];
|
|
810
|
-
const to = positions[j];
|
|
811
|
-
const currentAngle = this.ephem.calculateAspectAngle(from.longitude, to.longitude);
|
|
812
|
-
for (const aspect of ASPECTS) {
|
|
813
|
-
const orb = Math.abs(currentAngle - aspect.angle);
|
|
814
|
-
if (orb > aspect.orb || orb > orbDegrees) {
|
|
815
|
-
continue;
|
|
816
|
-
}
|
|
817
|
-
const applying = this.isElectionalAspectApplying(from, to, aspect.angle);
|
|
818
|
-
if (!applying) {
|
|
819
|
-
continue;
|
|
820
|
-
}
|
|
821
|
-
aspects.push({
|
|
822
|
-
from_body: from.planet,
|
|
823
|
-
to_body: to.planet,
|
|
824
|
-
aspect: aspect.name,
|
|
825
|
-
orb: Number.parseFloat(orb.toFixed(4)),
|
|
826
|
-
applying: true,
|
|
827
|
-
});
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
return aspects.sort((a, b) => a.orb - b.orb ||
|
|
832
|
-
a.from_body.localeCompare(b.from_body) ||
|
|
833
|
-
a.to_body.localeCompare(b.to_body) ||
|
|
834
|
-
a.aspect.localeCompare(b.aspect));
|
|
835
|
-
}
|
|
836
|
-
isElectionalAspectApplying(from, to, aspectAngle) {
|
|
837
|
-
const signedSeparation = this.getSignedAngularDifference(from.longitude, to.longitude);
|
|
838
|
-
const currentSeparation = Math.abs(signedSeparation);
|
|
839
|
-
if (currentSeparation === aspectAngle) {
|
|
840
|
-
return false;
|
|
841
|
-
}
|
|
842
|
-
const separationRate = Math.sign(signedSeparation || 1) * (to.speed - from.speed);
|
|
843
|
-
if (separationRate === 0) {
|
|
844
|
-
return false;
|
|
845
|
-
}
|
|
846
|
-
return currentSeparation < aspectAngle ? separationRate > 0 : separationRate < 0;
|
|
847
|
-
}
|
|
848
|
-
getSignedAngularDifference(fromLongitude, toLongitude) {
|
|
849
|
-
const normalized = ((toLongitude - fromLongitude + 540) % 360) - 180;
|
|
850
|
-
return normalized === -180 ? 180 : normalized;
|
|
851
|
-
}
|
|
852
|
-
getElectionalPhaseName(phaseAngle) {
|
|
853
|
-
if (phaseAngle < 45)
|
|
854
|
-
return 'new';
|
|
855
|
-
if (phaseAngle < 90)
|
|
856
|
-
return 'crescent';
|
|
857
|
-
if (phaseAngle < 135)
|
|
858
|
-
return 'first_quarter';
|
|
859
|
-
if (phaseAngle < 180)
|
|
860
|
-
return 'gibbous';
|
|
861
|
-
if (phaseAngle < 225)
|
|
862
|
-
return 'full';
|
|
863
|
-
if (phaseAngle < 270)
|
|
864
|
-
return 'disseminating';
|
|
865
|
-
if (phaseAngle < 315)
|
|
866
|
-
return 'last_quarter';
|
|
867
|
-
return 'balsamic';
|
|
868
|
-
}
|
|
869
|
-
getTraditionalSignRuler(sign) {
|
|
870
|
-
const signRulers = {
|
|
871
|
-
Aries: 'Mars',
|
|
872
|
-
Taurus: 'Venus',
|
|
873
|
-
Gemini: 'Mercury',
|
|
874
|
-
Cancer: 'Moon',
|
|
875
|
-
Leo: 'Sun',
|
|
876
|
-
Virgo: 'Mercury',
|
|
877
|
-
Libra: 'Venus',
|
|
878
|
-
Scorpio: 'Mars',
|
|
879
|
-
Sagittarius: 'Jupiter',
|
|
880
|
-
Capricorn: 'Saturn',
|
|
881
|
-
Aquarius: 'Saturn',
|
|
882
|
-
Pisces: 'Jupiter',
|
|
883
|
-
};
|
|
884
|
-
return signRulers[sign] ?? 'Mars';
|
|
164
|
+
return this.risingSignService.getRisingSignWindows(input);
|
|
885
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Return the currently retrograde planets for the requested reporting timezone.
|
|
168
|
+
*/
|
|
886
169
|
getRetrogradePlanets(timezone) {
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
date: dateLabel,
|
|
897
|
-
timezone: resolvedTimezone,
|
|
898
|
-
planets: retrograde,
|
|
899
|
-
};
|
|
900
|
-
const humanText = retrograde.length === 0
|
|
901
|
-
? 'No planets are currently retrograde.'
|
|
902
|
-
: `Retrograde Planets:\n\n${retrograde.map((p) => `${p.planet}: ${p.degree.toFixed(2)}° ${p.sign}`).join('\n')}`;
|
|
903
|
-
return { data: structuredData, text: humanText };
|
|
904
|
-
}
|
|
170
|
+
return this.skyService.getRetrogradePlanets(timezone);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Return the next rise and set events after the local day anchor for the chart location.
|
|
174
|
+
*
|
|
175
|
+
* @remarks
|
|
176
|
+
* The lookup anchor remains local midnight in the natal chart timezone even
|
|
177
|
+
* when reporting text uses a preferred reporting timezone.
|
|
178
|
+
*/
|
|
905
179
|
async getRiseSetTimes(natalChart) {
|
|
906
|
-
|
|
907
|
-
const reportingTimezone = this.mcpStartupDefaults.preferredTimezone || timezone;
|
|
908
|
-
const now = this.now();
|
|
909
|
-
const localNow = utcToLocal(now, timezone);
|
|
910
|
-
const localMidnight = {
|
|
911
|
-
year: localNow.year,
|
|
912
|
-
month: localNow.month,
|
|
913
|
-
day: localNow.day,
|
|
914
|
-
hour: 0,
|
|
915
|
-
minute: 0,
|
|
916
|
-
second: 0,
|
|
917
|
-
};
|
|
918
|
-
const midnightUTC = localToUTC(localMidnight, timezone);
|
|
919
|
-
const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
|
|
920
|
-
const results = await this.riseSetCalc.getAllRiseSet(midnightUTC, natalChart.location.latitude, natalChart.location.longitude);
|
|
921
|
-
const structuredData = {
|
|
922
|
-
date: dateLabel,
|
|
923
|
-
timezone,
|
|
924
|
-
times: results.map((r) => ({
|
|
925
|
-
planet: r.planet,
|
|
926
|
-
rise: r.rise?.toISOString() ?? null,
|
|
927
|
-
set: r.set?.toISOString() ?? null,
|
|
928
|
-
})),
|
|
929
|
-
};
|
|
930
|
-
const humanText = `Rise/Set Times:\n\n${results
|
|
931
|
-
.map((r) => {
|
|
932
|
-
const rise = r.rise ? this.formatTimestamp(r.rise, reportingTimezone) : 'none';
|
|
933
|
-
const set = r.set ? this.formatTimestamp(r.set, reportingTimezone) : 'none';
|
|
934
|
-
return `${r.planet}: Rise ${rise}, Set ${set}`;
|
|
935
|
-
})
|
|
936
|
-
.join('\n')}`;
|
|
937
|
-
return {
|
|
938
|
-
data: structuredData,
|
|
939
|
-
text: humanText,
|
|
940
|
-
};
|
|
180
|
+
return this.skyService.getRiseSetTimes(natalChart);
|
|
941
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Return current asteroid and node positions for the requested reporting timezone.
|
|
184
|
+
*/
|
|
942
185
|
getAsteroidPositions(timezone) {
|
|
943
|
-
|
|
944
|
-
const now = this.now();
|
|
945
|
-
const jd = this.ephem.dateToJulianDay(now);
|
|
946
|
-
const asteroidIds = [...ASTEROIDS, ...NODES];
|
|
947
|
-
const positions = this.ephem.getAllPlanets(jd, asteroidIds);
|
|
948
|
-
const localNow = utcToLocal(now, resolvedTimezone);
|
|
949
|
-
const dateLabel = `${localNow.year}-${String(localNow.month).padStart(2, '0')}-${String(localNow.day).padStart(2, '0')}`;
|
|
950
|
-
const structuredData = {
|
|
951
|
-
date: dateLabel,
|
|
952
|
-
timezone: resolvedTimezone,
|
|
953
|
-
positions,
|
|
954
|
-
};
|
|
955
|
-
const humanText = `Asteroid & Node Positions:\n\n${positions
|
|
956
|
-
.map((p) => {
|
|
957
|
-
const rx = p.isRetrograde ? ' Rx' : '';
|
|
958
|
-
return `${p.planet}: ${p.degree.toFixed(2)}° ${p.sign}${rx}`;
|
|
959
|
-
})
|
|
960
|
-
.join('\n')}`;
|
|
961
|
-
return {
|
|
962
|
-
data: structuredData,
|
|
963
|
-
text: humanText,
|
|
964
|
-
};
|
|
186
|
+
return this.skyService.getAsteroidPositions(timezone);
|
|
965
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* Look up the next solar and lunar eclipses after the current instant.
|
|
190
|
+
*/
|
|
966
191
|
getNextEclipses(timezone) {
|
|
967
|
-
|
|
968
|
-
const now = this.now();
|
|
969
|
-
const jd = this.ephem.dateToJulianDay(now);
|
|
970
|
-
const solarEclipse = this.eclipseCalc.findNextSolarEclipse(jd);
|
|
971
|
-
const lunarEclipse = this.eclipseCalc.findNextLunarEclipse(jd);
|
|
972
|
-
const eclipses = [];
|
|
973
|
-
const humanLines = [];
|
|
974
|
-
if (solarEclipse) {
|
|
975
|
-
eclipses.push({
|
|
976
|
-
type: solarEclipse.type,
|
|
977
|
-
eclipseType: solarEclipse.eclipseType,
|
|
978
|
-
maxTime: solarEclipse.maxTime.toISOString(),
|
|
979
|
-
});
|
|
980
|
-
humanLines.push(`Next Solar Eclipse: ${this.formatTimestamp(solarEclipse.maxTime, resolvedTimezone)} (${solarEclipse.eclipseType})`);
|
|
981
|
-
}
|
|
982
|
-
if (lunarEclipse) {
|
|
983
|
-
eclipses.push({
|
|
984
|
-
type: lunarEclipse.type,
|
|
985
|
-
eclipseType: lunarEclipse.eclipseType,
|
|
986
|
-
maxTime: lunarEclipse.maxTime.toISOString(),
|
|
987
|
-
});
|
|
988
|
-
humanLines.push(`Next Lunar Eclipse: ${this.formatTimestamp(lunarEclipse.maxTime, resolvedTimezone)} (${lunarEclipse.eclipseType})`);
|
|
989
|
-
}
|
|
990
|
-
const structuredData = { timezone: resolvedTimezone, eclipses };
|
|
991
|
-
const humanText = eclipses.length === 0
|
|
992
|
-
? 'No eclipses found in the near future.'
|
|
993
|
-
: `Upcoming Eclipses:\n\n${humanLines.join('\n')}`;
|
|
994
|
-
return { data: structuredData, text: humanText };
|
|
192
|
+
return this.skyService.getNextEclipses(timezone);
|
|
995
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Summarize process-local server state and configured startup defaults.
|
|
196
|
+
*/
|
|
996
197
|
getServerStatus(natalChart) {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
},
|
|
1007
|
-
ephemerisInitialized: this.isInitialized(),
|
|
1008
|
-
stateModel: 'stateful-per-process',
|
|
1009
|
-
};
|
|
1010
|
-
const humanText = natalChart
|
|
1011
|
-
? `Server ready. Natal chart loaded: ${natalChart.name} (${natalChart.location.timezone})`
|
|
1012
|
-
: 'Server ready. No natal chart loaded — call set_natal_chart first.';
|
|
1013
|
-
return { data: statusData, text: humanText };
|
|
1014
|
-
}
|
|
198
|
+
return this.natalService.getServerStatus(natalChart);
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Generate a natal chart image or SVG for the current chart.
|
|
202
|
+
*
|
|
203
|
+
* @remarks
|
|
204
|
+
* When `output_path` is omitted the payload is returned inline; otherwise the
|
|
205
|
+
* rendered asset is written to disk and only path metadata is returned.
|
|
206
|
+
*/
|
|
1015
207
|
async generateNatalChart(natalChart, input = {}) {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
await this.writeFileFn(outputPath, chart);
|
|
1026
|
-
}
|
|
1027
|
-
return {
|
|
1028
|
-
format,
|
|
1029
|
-
outputPath,
|
|
1030
|
-
text: `Natal Chart for ${natalChart.name} saved to: ${outputPath}`,
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
1033
|
-
if (format === 'svg') {
|
|
1034
|
-
return {
|
|
1035
|
-
format,
|
|
1036
|
-
text: `Natal Chart for ${natalChart.name}:`,
|
|
1037
|
-
svg: chart,
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
const base64 = chart.toString('base64');
|
|
1041
|
-
const mimeType = format === 'png' ? 'image/png' : 'image/webp';
|
|
1042
|
-
return {
|
|
1043
|
-
format,
|
|
1044
|
-
text: `Natal Chart for ${natalChart.name} (${theme} theme, ${format.toUpperCase()} format):`,
|
|
1045
|
-
image: {
|
|
1046
|
-
data: base64,
|
|
1047
|
-
mimeType,
|
|
1048
|
-
},
|
|
1049
|
-
};
|
|
1050
|
-
}
|
|
208
|
+
return this.chartOutputService.generateNatalChart(natalChart, input);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Generate a transit chart image or SVG for a target date.
|
|
212
|
+
*
|
|
213
|
+
* @remarks
|
|
214
|
+
* Omitted dates still resolve to local noon in the natal chart timezone before
|
|
215
|
+
* rendering so date-only behavior stays stable across timezone conversions.
|
|
216
|
+
*/
|
|
1051
217
|
async generateTransitChart(natalChart, input = {}) {
|
|
1052
|
-
|
|
1053
|
-
const theme = input.theme ?? getDefaultTheme(natalChart.location.timezone);
|
|
1054
|
-
const format = input.format ?? 'svg';
|
|
1055
|
-
let targetDate;
|
|
1056
|
-
if (dateStr) {
|
|
1057
|
-
const parsed = parseDateOnlyInput(dateStr);
|
|
1058
|
-
targetDate = localToUTC(parsed, natalChart.location.timezone);
|
|
1059
|
-
}
|
|
1060
|
-
else {
|
|
1061
|
-
const now = this.now();
|
|
1062
|
-
const localNow = utcToLocal(now, natalChart.location.timezone);
|
|
1063
|
-
const localNoon = { ...localNow, hour: 12, minute: 0, second: 0 };
|
|
1064
|
-
targetDate = localToUTC(localNoon, natalChart.location.timezone);
|
|
1065
|
-
}
|
|
1066
|
-
const outputPath = input.output_path;
|
|
1067
|
-
const chart = await this.chartRenderer.generateTransitChart(natalChart, targetDate, theme, format);
|
|
1068
|
-
const dateLabel = formatDateOnly(targetDate, natalChart.location.timezone);
|
|
1069
|
-
if (outputPath) {
|
|
1070
|
-
if (format === 'svg') {
|
|
1071
|
-
await this.writeFileFn(outputPath, chart, 'utf-8');
|
|
1072
|
-
}
|
|
1073
|
-
else {
|
|
1074
|
-
await this.writeFileFn(outputPath, chart);
|
|
1075
|
-
}
|
|
1076
|
-
return {
|
|
1077
|
-
format,
|
|
1078
|
-
outputPath,
|
|
1079
|
-
text: `Transit Chart for ${natalChart.name} (${dateLabel}) saved to ${outputPath}`,
|
|
1080
|
-
};
|
|
1081
|
-
}
|
|
1082
|
-
if (format === 'svg') {
|
|
1083
|
-
return {
|
|
1084
|
-
format,
|
|
1085
|
-
text: `Transit Chart for ${natalChart.name} (${dateLabel})`,
|
|
1086
|
-
svg: chart,
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
1089
|
-
const base64 = chart.toString('base64');
|
|
1090
|
-
const mimeType = format === 'png' ? 'image/png' : 'image/webp';
|
|
1091
|
-
return {
|
|
1092
|
-
format,
|
|
1093
|
-
text: `Transit Chart for ${natalChart.name} (${dateLabel}, ${theme} theme, ${format.toUpperCase()} format):`,
|
|
1094
|
-
image: {
|
|
1095
|
-
data: base64,
|
|
1096
|
-
mimeType,
|
|
1097
|
-
},
|
|
1098
|
-
};
|
|
218
|
+
return this.chartOutputService.generateTransitChart(natalChart, input);
|
|
1099
219
|
}
|
|
1100
220
|
}
|