@orrery/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,221 @@
1
+ # @orrery/core
2
+
3
+ 브라우저/Node.js 환경에서 동작하는 동양·서양 점술 계산 엔진입니다.
4
+
5
+ - **사주팔자(四柱八字)** — 60갑자, 십신, 12운성, 12신살, 대운, 간지 관계 분석
6
+ - **자미두수(紫微斗數)** — 명반(命盤) 생성, 대한(大限), 유년(流年)/유월(流月) 분석
7
+ - **서양 점성술 출생차트(Natal Chart)** — 행성 위치, 하우스, 앵글, 애스펙트 (Swiss Ephemeris WASM)
8
+
9
+ 백엔드 불필요. 모든 계산이 클라이언트에서 수행됩니다.
10
+
11
+ **[라이브 데모 →](https://rath.github.io/orrery/)**
12
+
13
+ ## 크레딧
14
+
15
+ - **사주 만세력** — 고영창님의 Perl [진짜만세력](http://afnmp3.homeip.net/~kohyc/calendar/cal20000.html)를 김정균님이 [PHP로 포팅](https://github.com/OOPS-ORG-PHP/Lunar)한 것을, 2018년 11월 황장호가 Java와 Python으로 재포팅하여 사용해오다가, 2026년 2월 Claude Code (Opus 4.6)로 TypeScript로 포팅
16
+ - **자미두수 명반** — [lunar-javascript](https://www.npmjs.com/package/lunar-javascript) 라이브러리 기반으로 Claude (Opus 4.5)가 중국어 문헌을 리서치하며 구현
17
+ - **점성술 출생차트** — [Swiss Ephemeris](https://github.com/arturania/swisseph)를 WASM으로 빌드한 [swisseph-wasm](https://www.npmjs.com/package/swisseph-wasm) 기반
18
+
19
+ ## 설치
20
+
21
+ ```bash
22
+ npm install @orrery/core
23
+ ```
24
+
25
+ 서양 점성술(natal) 기능을 사용하려면 `swisseph-wasm`도 설치하세요:
26
+
27
+ ```bash
28
+ npm install swisseph-wasm
29
+ ```
30
+
31
+ > `swisseph-wasm`은 optional peer dependency입니다. 사주/자미두수만 사용한다면 설치하지 않아도 됩니다.
32
+
33
+ ## 사용법
34
+
35
+ ### 사주팔자 (四柱八字)
36
+
37
+ ```typescript
38
+ import { calculateSaju } from '@orrery/core/saju'
39
+ import type { BirthInput } from '@orrery/core/types'
40
+
41
+ const input: BirthInput = {
42
+ year: 1993, month: 3, day: 12,
43
+ hour: 9, minute: 45,
44
+ gender: 'M',
45
+ }
46
+
47
+ const result = calculateSaju(input)
48
+
49
+ // 사주 4주 (시, 일, 월, 년 순서)
50
+ for (const p of result.pillars) {
51
+ console.log(p.pillar.ganzi) // '乙巳', '壬辰', '乙卯', '癸酉'
52
+ console.log(p.stemSipsin) // 천간 십신
53
+ console.log(p.branchSipsin) // 지지 십신
54
+ console.log(p.unseong) // 12운성
55
+ }
56
+
57
+ // 대운
58
+ for (const dw of result.daewoon) {
59
+ console.log(`${dw.ganzi} (${dw.age}세~)`)
60
+ }
61
+
62
+ // 간지 관계 (합, 충, 형, 파, 해)
63
+ for (const [key, pair] of result.relations.pairs) {
64
+ console.log(key, pair.stem, pair.branch)
65
+ }
66
+ ```
67
+
68
+ ### 자미두수 (紫微斗數)
69
+
70
+ ```typescript
71
+ import { createChart, calculateLiunian, getDaxianList } from '@orrery/core/ziwei'
72
+
73
+ // 명반 생성
74
+ const chart = createChart(1993, 3, 12, 9, 45, true)
75
+
76
+ console.log(chart.mingGongZhi) // 명궁 지지
77
+ console.log(chart.shenGongZhi) // 신궁 지지
78
+ console.log(chart.wuXingJu.name) // 오행국 (예: '水二局')
79
+
80
+ // 각 궁위의 성요 확인
81
+ for (const [name, palace] of Object.entries(chart.palaces)) {
82
+ const stars = palace.stars.map(s => {
83
+ const sihua = s.siHua ? `(${s.siHua})` : ''
84
+ return `${s.name}${s.brightness}${sihua}`
85
+ })
86
+ console.log(`${name} [${palace.ganZhi}]: ${stars.join(', ')}`)
87
+ }
88
+
89
+ // 대한 (大限)
90
+ const daxianList = getDaxianList(chart)
91
+ for (const dx of daxianList) {
92
+ console.log(`${dx.ageStart}~${dx.ageEnd}세: ${dx.palaceName} ${dx.ganZhi}`)
93
+ }
94
+
95
+ // 유년 (流年) — 특정 연도의 운세
96
+ const liunian = calculateLiunian(chart, 2026)
97
+ console.log(liunian.natalPalaceAtMing) // 유년 명궁이 위치한 본명반 궁위
98
+ console.log(liunian.siHua) // 유년 사화
99
+ ```
100
+
101
+ ### 서양 점성술 (Natal Chart)
102
+
103
+ ```typescript
104
+ import { calculateNatal } from '@orrery/core/natal'
105
+ import type { BirthInput } from '@orrery/core/types'
106
+
107
+ const input: BirthInput = {
108
+ year: 1993, month: 3, day: 12,
109
+ hour: 9, minute: 45,
110
+ gender: 'M',
111
+ latitude: 37.5665, // 서울 (선택사항, 기본값: 서울)
112
+ longitude: 126.9780,
113
+ }
114
+
115
+ const chart = await calculateNatal(input)
116
+
117
+ // 행성 위치
118
+ for (const planet of chart.planets) {
119
+ console.log(`${planet.id}: ${planet.sign} ${planet.degreeInSign.toFixed(1)}°`)
120
+ // 'Sun: Pisces 21.6°'
121
+ }
122
+
123
+ // ASC / MC
124
+ console.log(`ASC: ${chart.angles.asc.sign}`)
125
+ console.log(`MC: ${chart.angles.mc.sign}`)
126
+
127
+ // 하우스 (기본: Placidus)
128
+ for (const house of chart.houses) {
129
+ console.log(`House ${house.number}: ${house.sign} ${house.degreeInSign.toFixed(1)}°`)
130
+ }
131
+
132
+ // 애스펙트
133
+ for (const aspect of chart.aspects) {
134
+ console.log(`${aspect.planet1} ${aspect.type} ${aspect.planet2} (orb: ${aspect.orb.toFixed(1)}°)`)
135
+ }
136
+
137
+ // 하우스 시스템 변경 (Koch)
138
+ const kochChart = await calculateNatal(input, 'K')
139
+ ```
140
+
141
+ ### 저수준 API
142
+
143
+ 개별 함수를 직접 사용할 수도 있습니다:
144
+
145
+ ```typescript
146
+ import { getFourPillars, getDaewoon, getRelation } from '@orrery/core/pillars'
147
+ import { STEM_INFO, ELEMENT_HANJA } from '@orrery/core/constants'
148
+
149
+ // 사주 4주만 계산
150
+ const [년주, 월주, 일주, 시주] = getFourPillars(1993, 3, 12, 9, 45)
151
+ console.log(년주, 월주, 일주, 시주) // '癸酉', '乙卯', '壬辰', '乙巳'
152
+
153
+ // 십신 관계
154
+ const relation = getRelation('壬', '乙')
155
+ console.log(relation?.hanja) // '傷官'
156
+
157
+ // 천간 오행 정보
158
+ console.log(STEM_INFO['壬']) // { yinyang: '+', element: 'water' }
159
+ console.log(ELEMENT_HANJA['water']) // '水'
160
+ ```
161
+
162
+ ### 도시 데이터
163
+
164
+ 출생 위치 입력에 활용할 수 있는 한국/세계 주요 도시 데이터를 제공합니다:
165
+
166
+ ```typescript
167
+ import { SEOUL, filterCities, formatCityName } from '@orrery/core/cities'
168
+
169
+ console.log(SEOUL) // { name: '서울', lat: 37.5665, lon: 126.9780 }
170
+
171
+ // 한글 초성 검색 지원
172
+ const results = filterCities('ㅂㅅ') // '부산' 매칭
173
+ console.log(formatCityName(results[0])) // '부산'
174
+ ```
175
+
176
+ ## 예제 실행
177
+
178
+ 저장소를 클론한 후 바로 실행해볼 수 있는 예제 스크립트가 있습니다:
179
+
180
+ ```bash
181
+ git clone https://github.com/rath/orrery.git
182
+ cd orrery
183
+ bun install
184
+
185
+ # 사주팔자
186
+ bun packages/core/examples/saju.ts
187
+
188
+ # 자미두수
189
+ bun packages/core/examples/ziwei.ts
190
+
191
+ # 서양 점성술 출생차트
192
+ bun packages/core/examples/natal.ts
193
+ ```
194
+
195
+ ## Subpath Exports
196
+
197
+ 필요한 모듈만 선택적으로 import할 수 있습니다:
198
+
199
+ | 경로 | 설명 |
200
+ |------|------|
201
+ | `@orrery/core` | 전체 barrel export |
202
+ | `@orrery/core/saju` | `calculateSaju()` |
203
+ | `@orrery/core/ziwei` | `createChart()`, `calculateLiunian()`, `getDaxianList()` |
204
+ | `@orrery/core/natal` | `calculateNatal()`, 별자리/행성 심볼, 포맷 함수 |
205
+ | `@orrery/core/pillars` | `getFourPillars()`, `getDaewoon()` 등 저수준 API |
206
+ | `@orrery/core/types` | 모든 TypeScript 타입/인터페이스 |
207
+ | `@orrery/core/constants` | 천간/지지, 십신, 궁위명 등 상수 테이블 |
208
+ | `@orrery/core/cities` | 도시 데이터, 검색 함수 |
209
+
210
+ ## 의존성
211
+
212
+ | 패키지 | 유형 | 용도 |
213
+ |--------|------|------|
214
+ | `lunar-javascript` | dependency | 음력 변환 (자미두수) |
215
+ | `swisseph-wasm` | optional peer | Swiss Ephemeris WASM (서양 점성술) |
216
+
217
+ `swisseph-wasm`을 설치하지 않으면 사주/자미두수만 사용 가능합니다.
218
+
219
+ ## 라이선스
220
+
221
+ MIT
@@ -0,0 +1,292 @@
1
+ // src/natal.ts
2
+ import SwissEph from "swisseph-wasm";
3
+ var DEFAULT_LAT = 37.5194;
4
+ var DEFAULT_LON = 127.0992;
5
+ var ZODIAC_SIGNS = [
6
+ "Aries",
7
+ "Taurus",
8
+ "Gemini",
9
+ "Cancer",
10
+ "Leo",
11
+ "Virgo",
12
+ "Libra",
13
+ "Scorpio",
14
+ "Sagittarius",
15
+ "Capricorn",
16
+ "Aquarius",
17
+ "Pisces"
18
+ ];
19
+ var ZODIAC_SYMBOLS = {
20
+ Aries: "\u2648",
21
+ Taurus: "\u2649",
22
+ Gemini: "\u264A",
23
+ Cancer: "\u264B",
24
+ Leo: "\u264C",
25
+ Virgo: "\u264D",
26
+ Libra: "\u264E",
27
+ Scorpio: "\u264F",
28
+ Sagittarius: "\u2650",
29
+ Capricorn: "\u2651",
30
+ Aquarius: "\u2652",
31
+ Pisces: "\u2653"
32
+ };
33
+ var ZODIAC_KO = {
34
+ Aries: "\uC591\uC790\uB9AC",
35
+ Taurus: "\uD669\uC18C\uC790\uB9AC",
36
+ Gemini: "\uC30D\uB465\uC774\uC790\uB9AC",
37
+ Cancer: "\uAC8C\uC790\uB9AC",
38
+ Leo: "\uC0AC\uC790\uC790\uB9AC",
39
+ Virgo: "\uCC98\uB140\uC790\uB9AC",
40
+ Libra: "\uCC9C\uCE6D\uC790\uB9AC",
41
+ Scorpio: "\uC804\uAC08\uC790\uB9AC",
42
+ Sagittarius: "\uAD81\uC218\uC790\uB9AC",
43
+ Capricorn: "\uC5FC\uC18C\uC790\uB9AC",
44
+ Aquarius: "\uBB3C\uBCD1\uC790\uB9AC",
45
+ Pisces: "\uBB3C\uACE0\uAE30\uC790\uB9AC"
46
+ };
47
+ var PLANET_SYMBOLS = {
48
+ Sun: "\u2609",
49
+ Moon: "\u263D",
50
+ Mercury: "\u263F",
51
+ Venus: "\u2640",
52
+ Mars: "\u2642",
53
+ Jupiter: "\u2643",
54
+ Saturn: "\u2644",
55
+ Uranus: "\u2645",
56
+ Neptune: "\u2646",
57
+ Pluto: "\u2647",
58
+ Chiron: "\u26B7",
59
+ NorthNode: "\u260A",
60
+ SouthNode: "\u260B"
61
+ };
62
+ var PLANET_KO = {
63
+ Sun: "\uD0DC\uC591",
64
+ Moon: "\uB2EC",
65
+ Mercury: "\uC218\uC131",
66
+ Venus: "\uAE08\uC131",
67
+ Mars: "\uD654\uC131",
68
+ Jupiter: "\uBAA9\uC131",
69
+ Saturn: "\uD1A0\uC131",
70
+ Uranus: "\uCC9C\uC655\uC131",
71
+ Neptune: "\uD574\uC655\uC131",
72
+ Pluto: "\uBA85\uC655\uC131",
73
+ Chiron: "\uD0A4\uB860",
74
+ NorthNode: "\uBD81\uAD50\uC810",
75
+ SouthNode: "\uB0A8\uAD50\uC810"
76
+ };
77
+ var PLANET_BODIES = [
78
+ ["Sun", 0],
79
+ // SE_SUN
80
+ ["Moon", 1],
81
+ // SE_MOON
82
+ ["Mercury", 2],
83
+ // SE_MERCURY
84
+ ["Venus", 3],
85
+ // SE_VENUS
86
+ ["Mars", 4],
87
+ // SE_MARS
88
+ ["Jupiter", 5],
89
+ // SE_JUPITER
90
+ ["Saturn", 6],
91
+ // SE_SATURN
92
+ ["Uranus", 7],
93
+ // SE_URANUS
94
+ ["Neptune", 8],
95
+ // SE_NEPTUNE
96
+ ["Pluto", 9],
97
+ // SE_PLUTO
98
+ ["Chiron", 15],
99
+ // SE_CHIRON
100
+ ["NorthNode", 10]
101
+ // SE_MEAN_NODE
102
+ ];
103
+ var ASPECT_SYMBOLS = {
104
+ conjunction: "\u260C",
105
+ sextile: "\u26B9",
106
+ square: "\u25A1",
107
+ trine: "\u25B3",
108
+ opposition: "\u260D"
109
+ };
110
+ var ASPECT_DEFS = [
111
+ { type: "conjunction", angle: 0, maxOrb: 8 },
112
+ { type: "sextile", angle: 60, maxOrb: 6 },
113
+ { type: "square", angle: 90, maxOrb: 8 },
114
+ { type: "trine", angle: 120, maxOrb: 8 },
115
+ { type: "opposition", angle: 180, maxOrb: 8 }
116
+ ];
117
+ var ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"];
118
+ var HOUSE_SYSTEMS = [
119
+ ["P", "Placidus"],
120
+ ["K", "Koch"],
121
+ ["O", "Porphyrius"],
122
+ ["R", "Regiomontanus"],
123
+ ["C", "Campanus"],
124
+ ["E", "Equal"],
125
+ ["W", "Whole Sign"],
126
+ ["B", "Alcabitus"],
127
+ ["M", "Morinus"],
128
+ ["T", "Topocentric"]
129
+ ];
130
+ var sweInstance = null;
131
+ var initPromise = null;
132
+ async function getSwissEph() {
133
+ if (sweInstance) return sweInstance;
134
+ if (initPromise) return initPromise;
135
+ initPromise = (async () => {
136
+ const swe = new SwissEph();
137
+ await swe.initSwissEph();
138
+ sweInstance = swe;
139
+ return swe;
140
+ })();
141
+ return initPromise;
142
+ }
143
+ function lonToSign(lon) {
144
+ return ZODIAC_SIGNS[Math.trunc(normalizeDeg(lon) / 30)];
145
+ }
146
+ function normalizeDeg(deg) {
147
+ return (deg % 360 + 360) % 360;
148
+ }
149
+ function degreeInSign(lon) {
150
+ return normalizeDeg(lon) % 30;
151
+ }
152
+ function angularDifference(lon1, lon2) {
153
+ let diff = Math.abs(normalizeDeg(lon1) - normalizeDeg(lon2));
154
+ if (diff > 180) diff = 360 - diff;
155
+ return diff;
156
+ }
157
+ function formatDegree(lon) {
158
+ const d = degreeInSign(lon);
159
+ const deg = Math.trunc(d);
160
+ const min = Math.trunc((d - deg) * 60);
161
+ return `${deg}\xB0${String(min).padStart(2, "0")}'`;
162
+ }
163
+ function findHouse(planetLon, cusps) {
164
+ const lon = normalizeDeg(planetLon);
165
+ for (let i = 1; i <= 12; i++) {
166
+ const start = normalizeDeg(cusps[i]);
167
+ const end = normalizeDeg(cusps[i === 12 ? 1 : i + 1]);
168
+ if (start < end) {
169
+ if (lon >= start && lon < end) return i;
170
+ } else {
171
+ if (lon >= start || lon < end) return i;
172
+ }
173
+ }
174
+ return 1;
175
+ }
176
+ function calculateAspects(planets) {
177
+ const aspects = [];
178
+ for (let i = 0; i < planets.length; i++) {
179
+ for (let j = i + 1; j < planets.length; j++) {
180
+ const p1 = planets[i];
181
+ const p2 = planets[j];
182
+ const diff = angularDifference(p1.longitude, p2.longitude);
183
+ for (const def of ASPECT_DEFS) {
184
+ const orb = Math.abs(diff - def.angle);
185
+ if (orb <= def.maxOrb) {
186
+ aspects.push({
187
+ planet1: p1.id,
188
+ planet2: p2.id,
189
+ type: def.type,
190
+ angle: def.angle,
191
+ orb: Math.round(orb * 10) / 10
192
+ });
193
+ }
194
+ }
195
+ }
196
+ }
197
+ aspects.sort((a, b) => a.orb - b.orb);
198
+ return aspects;
199
+ }
200
+ async function calculateNatal(input, houseSystem = "P") {
201
+ const swe = await getSwissEph();
202
+ const lat = input.latitude ?? DEFAULT_LAT;
203
+ const lon = input.longitude ?? DEFAULT_LON;
204
+ const utHourDecimal = input.hour + input.minute / 60 - 9;
205
+ let utYear = input.year;
206
+ let utMonth = input.month;
207
+ let utDay = input.day;
208
+ let utHour = utHourDecimal;
209
+ if (utHour < 0) {
210
+ utHour += 24;
211
+ const d = new Date(utYear, utMonth - 1, utDay - 1);
212
+ utYear = d.getFullYear();
213
+ utMonth = d.getMonth() + 1;
214
+ utDay = d.getDate();
215
+ } else if (utHour >= 24) {
216
+ utHour -= 24;
217
+ const d = new Date(utYear, utMonth - 1, utDay + 1);
218
+ utYear = d.getFullYear();
219
+ utMonth = d.getMonth() + 1;
220
+ utDay = d.getDate();
221
+ }
222
+ const jd = swe.julday(utYear, utMonth, utDay, utHour);
223
+ const housesEx = swe.houses_ex.bind(swe);
224
+ const { cusps, ascmc } = housesEx(jd, 0, lat, lon, houseSystem);
225
+ const calcFlags = swe.SEFLG_SWIEPH | swe.SEFLG_SPEED;
226
+ const planets = [];
227
+ for (const [id, bodyNum] of PLANET_BODIES) {
228
+ const pos = swe.calc_ut(jd, bodyNum, calcFlags);
229
+ const longitude = pos[0];
230
+ planets.push({
231
+ id,
232
+ longitude,
233
+ latitude: pos[1],
234
+ speed: pos[3],
235
+ sign: lonToSign(longitude),
236
+ degreeInSign: degreeInSign(longitude),
237
+ isRetrograde: pos[3] < 0,
238
+ house: findHouse(longitude, cusps)
239
+ });
240
+ }
241
+ const northNode = planets.find((p) => p.id === "NorthNode");
242
+ const southLon = normalizeDeg(northNode.longitude + 180);
243
+ planets.push({
244
+ id: "SouthNode",
245
+ longitude: southLon,
246
+ latitude: -northNode.latitude,
247
+ speed: northNode.speed,
248
+ sign: lonToSign(southLon),
249
+ degreeInSign: degreeInSign(southLon),
250
+ isRetrograde: false,
251
+ house: findHouse(southLon, cusps)
252
+ });
253
+ const houses = [];
254
+ for (let i = 1; i <= 12; i++) {
255
+ const cuspLon = cusps[i];
256
+ houses.push({
257
+ number: i,
258
+ cuspLongitude: cuspLon,
259
+ sign: lonToSign(cuspLon),
260
+ degreeInSign: degreeInSign(cuspLon)
261
+ });
262
+ }
263
+ const ascLon = ascmc[0];
264
+ const mcLon = ascmc[1];
265
+ const descLon = normalizeDeg(ascLon + 180);
266
+ const icLon = normalizeDeg(mcLon + 180);
267
+ const angles = {
268
+ asc: { longitude: ascLon, sign: lonToSign(ascLon), degreeInSign: degreeInSign(ascLon) },
269
+ mc: { longitude: mcLon, sign: lonToSign(mcLon), degreeInSign: degreeInSign(mcLon) },
270
+ desc: { longitude: descLon, sign: lonToSign(descLon), degreeInSign: degreeInSign(descLon) },
271
+ ic: { longitude: icLon, sign: lonToSign(icLon), degreeInSign: degreeInSign(icLon) }
272
+ };
273
+ const aspectPlanets = planets.filter((p) => p.id !== "SouthNode");
274
+ const aspects = calculateAspects(aspectPlanets);
275
+ return { input, planets, houses, angles, aspects };
276
+ }
277
+
278
+ export {
279
+ ZODIAC_SIGNS,
280
+ ZODIAC_SYMBOLS,
281
+ ZODIAC_KO,
282
+ PLANET_SYMBOLS,
283
+ PLANET_KO,
284
+ ASPECT_SYMBOLS,
285
+ ROMAN,
286
+ HOUSE_SYSTEMS,
287
+ getSwissEph,
288
+ lonToSign,
289
+ normalizeDeg,
290
+ formatDegree,
291
+ calculateNatal
292
+ };