사주 풀이 고도화, NAS 배포 자동화

This commit is contained in:
2026-02-16 19:02:04 +09:00
parent d513c063cf
commit 7042373448
44 changed files with 6280 additions and 978 deletions

View File

@@ -1,316 +1,386 @@
import { SajuData } from './saju-calculator';
/**
* AI 기반 사주 해석
* 사주 데이터를 분석하여 상세한 해석 제공
*/
import {
SajuData, FIVE_ELEMENTS, HEAVENLY_STEMS,
getHiddenStems, getAllHiddenStems,
analyzeBranchInteractions, calculateShinsal, calculateGongmang,
getYearGanzi, FIVE_ELEMENTS_KR, EARTHLY_BRANCHES_KR, EARTHLY_BRANCHES,
BranchInteraction, Shinsal,
} from './saju-calculator';
import { DaeunPillar } from './daeun-calculator';
interface Interpretation {
personality: string[];
strengths: string[];
weaknesses: string[];
career: string[];
relationships: string[];
health: string[];
wealth: string[];
advice: string[];
// ============================================================
// 오행 밸런스 정밀 분석 (가중치 적용)
// ============================================================
export interface ElementBalance {
: number;
: number;
: number;
: number;
: number;
}
/**
* 오행 균형 분석
* 가중치 적용 오행 점수 계산
* - 천간: 1.0
* - 지지 본기(정기): 1.0
* - 지장간 중기: 0.5
* - 지장간 여기: 0.3
*/
function analyzeElementBalance(saju: SajuData): { [key: string]: number } {
const elements = { : 0, : 0, : 0, : 0, : 0 };
export function calculateDetailedElementBalance(saju: SajuData): ElementBalance {
const balance: ElementBalance = { : 0, : 0, : 0, : 0, : 0 };
// 사주팔자의 각 기둥에서 오행 카운트
elements[saju.year.element]++;
elements[saju.month.element]++;
elements[saju.day.element]++;
if (saju.hour) elements[saju.hour.element]++;
// 천간 오행 (각 1.0)
const stems = [saju.year.stem, saju.month.stem, saju.day.stem];
if (saju.hour) stems.push(saju.hour.stem);
return elements;
}
/**
* 십성 분석
*/
function analyzeTenGods(saju: SajuData): { [key: string]: number } {
const tenGods: { [key: string]: number } = {};
[saju.year.tenGod, saju.month.tenGod, saju.hour?.tenGod].forEach(god => {
if (god && god !== '일간') {
tenGods[god] = (tenGods[god] || 0) + 1;
}
});
return tenGods;
}
/**
* 일간 기반 성격 해석
*/
function interpretDayStem(stem: string, element: string): string[] {
const interpretations: { [key: string]: string[] } = {
'甲': [
'큰 나무처럼 곧고 꿋꿋한 성격입니다.',
'리더십이 강하고 개척 정신이 뛰어납니다.',
'정의감이 강하고 원칙을 중요시합니다.',
'때로는 융통성이 부족할 수 있습니다.'
],
'乙': [
'부드러운 풀처럼 유연하고 적응력이 뛰어납니다.',
'섬세하고 예술적 감각이 있습니다.',
'주변 환경에 잘 적응하며 협력을 중시합니다.',
'때로는 우유부단할 수 있습니다.'
],
'丙': [
'태양처럼 밝고 활발한 성격입니다.',
'사교성이 뛰어나고 열정적입니다.',
'창의적이고 표현력이 풍부합니다.',
'때로는 충동적일 수 있습니다.'
],
'丁': [
'촛불처럼 따뜻하고 섬세한 성격입니다.',
'예민하고 감수성이 풍부합니다.',
'예의 바르고 배려심이 깊습니다.',
'때로는 너무 예민할 수 있습니다.'
],
'戊': [
'산처럼 묵직하고 안정적인 성격입니다.',
'책임감이 강하고 신뢰할 수 있습니다.',
'현실적이고 실용적입니다.',
'때로는 고집이 셀 수 있습니다.'
],
'己': [
'밭처럼 포용력 있고 온화한 성격입니다.',
'배려심이 깊고 참을성이 강합니다.',
'현실적이며 실속을 챙깁니다.',
'때로는 소극적일 수 있습니다.'
],
'庚': [
'금속처럼 단단하고 강인한 성격입니다.',
'결단력이 있고 추진력이 강합니다.',
'정직하고 의리를 중시합니다.',
'때로는 융통성이 부족할 수 있습니다.'
],
'辛': [
'보석처럼 고귀하고 섬세한 성격입니다.',
'예리하고 통찰력이 뛰어납니다.',
'품위 있고 우아함을 추구합니다.',
'때로는 까다로울 수 있습니다.'
],
'壬': [
'큰 바다처럼 넓고 깊은 성격입니다.',
'지혜롭고 포용력이 있습니다.',
'융통성이 있고 적응력이 뛰어납니다.',
'때로는 변덕스러울 수 있습니다.'
],
'癸': [
'이슬처럼 섬세하고 조용한 성격입니다.',
'지적이고 사려 깊습니다.',
'인내심이 강하고 끈기가 있습니다.',
'때로는 소심할 수 있습니다.'
]
};
return interpretations[stem] || ['독특한 개성을 가진 사람입니다.'];
}
/**
* 직업 운세 분석
*/
function interpretCareer(saju: SajuData, tenGods: { [key: string]: number }): string[] {
const career: string[] = [];
const element = saju.day.element;
// 오행 기반 직업 추천
const careerByElement: { [key: string]: string[] } = {
'木': ['교육', '출판', '디자인', '패션', '임업', '환경'],
'火': ['예술', '광고', '방송', '요식업', 'IT', '전자'],
'土': ['부동산', '건설', '농업', '유통', '중개', '컨설팅'],
'金': ['금융', '법조', '의료', '기계', '자동차', '보석'],
'水': ['무역', '물류', '여행', '수산', '음료', '화학']
};
career.push(...careerByElement[element].slice(0, 3).map(c => `${c} 분야에 적성이 있습니다.`));
// 십성 기반 직업 성향
if (tenGods['정관'] || tenGods['편관']) {
career.push('조직 생활이나 공직에 적합합니다.');
}
if (tenGods['정재'] || tenGods['편재']) {
career.push('재물 관리나 사업에 능력이 있습니다.');
}
if (tenGods['식신'] || tenGods['상관']) {
career.push('창의적인 일이나 표현하는 직업이 좋습니다.');
}
if (tenGods['정인'] || tenGods['편인']) {
career.push('학문, 연구, 교육 분야가 적합합니다.');
for (const stem of stems) {
const elem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
if (elem) balance[elem] += 1.0;
}
return career;
}
// 지지 지장간 (본기 1.0, 중기 0.5, 여기 0.3)
const branches = [saju.year.branch, saju.month.branch, saju.day.branch];
if (saju.hour) branches.push(saju.hour.branch);
/**
* 대인 관계 분석
*/
function interpretRelationships(saju: SajuData, tenGods: { [key: string]: number }): string[] {
const relationships: string[] = [];
if (tenGods['비견'] || tenGods['겁재']) {
relationships.push('친구나 동료와의 관계가 중요합니다.');
relationships.push('경쟁심이 있지만 협력도 잘합니다.');
}
if (tenGods['정관'] || tenGods['편관']) {
relationships.push('윗사람의 인정을 받기 쉽습니다.');
relationships.push('사회적 명예를 중시합니다.');
}
if (tenGods['정재'] || tenGods['편재']) {
if (saju.gender === 'male') {
relationships.push('이성과의 인연이 좋습니다.');
} else {
relationships.push('재물 운이 좋습니다.');
const weights = [1.0, 0.5, 0.3];
for (const branch of branches) {
const hidden = getHiddenStems(branch);
for (let i = 0; i < hidden.length; i++) {
const elem = FIVE_ELEMENTS[hidden[i] as keyof typeof FIVE_ELEMENTS] as keyof ElementBalance;
if (elem) balance[elem] += weights[i] || 0.3;
}
}
if (tenGods['정인'] || tenGods['편인']) {
if (saju.gender === 'female') {
relationships.push('가족과의 유대가 깊습니다.');
} else {
relationships.push('멘토를 만나기 쉽습니다.');
}
// 소수점 둘째 자리로 반올림
for (const key of Object.keys(balance) as (keyof ElementBalance)[]) {
balance[key] = Math.round(balance[key] * 100) / 100;
}
return relationships;
return balance;
}
/**
* 건강 운세 분석
*/
function interpretHealth(saju: SajuData, elements: { [key: string]: number }): string[] {
const health: string[] = [];
const element = saju.day.element;
// 오행별 건강 주의사항
const healthByElement: { [key: string]: string } = {
'木': '간, 담낭, 눈 건강에 주의하세요.',
'火': '심장, 혈압, 소장 건강에 주의하세요.',
'土': '위장, 소화기, 비장 건강에 주의하세요.',
'金': '폐, 대장, 피부 건강에 주의하세요.',
'水': '신장, 방광, 생식기 건강에 주의하세요.'
};
health.push(healthByElement[element]);
// 오행 불균형 체크
const maxElement = Object.keys(elements).reduce((a, b) =>
elements[a] > elements[b] ? a : b
);
const minElement = Object.keys(elements).reduce((a, b) =>
elements[a] < elements[b] ? a : b
);
if (elements[maxElement] - elements[minElement] >= 2) {
health.push('오행 균형을 맞추기 위한 식습관 관리가 필요합니다.');
}
health.push('규칙적인 생활과 적절한 운동이 중요합니다.');
return health;
}
/**
* 재물 운세 분석
*/
function interpretWealth(saju: SajuData, tenGods: { [key: string]: number }): string[] {
const wealth: string[] = [];
if (tenGods['정재']) {
wealth.push('정직한 노력으로 재물을 모을 수 있습니다.');
wealth.push('월급이나 안정적인 수입이 좋습니다.');
}
if (tenGods['편재']) {
wealth.push('사업이나 투자로 재물을 얻을 수 있습니다.');
wealth.push('재테크에 관심을 가지면 좋습니다.');
}
if (tenGods['식신'] || tenGods['상관']) {
wealth.push('재능을 활용한 수입원이 있습니다.');
wealth.push('창의적인 일로 돈을 벌 수 있습니다.');
}
if (!tenGods['정재'] && !tenGods['편재']) {
wealth.push('재물보다는 명예나 학문을 추구합니다.');
wealth.push('꾸준한 저축이 중요합니다.');
}
return wealth;
}
/**
* 종합 조언
*/
function generateAdvice(saju: SajuData, elements: { [key: string]: number }): string[] {
const advice: string[] = [];
const element = saju.day.element;
// 오행별 조언
const adviceByElement: { [key: string]: string[] } = {
'木': ['아침 산책으로 하루를 시작하세요.', '녹색 식물을 가까이 하세요.', '독서로 마음을 충전하세요.'],
'火': ['밝은 색상의 옷을 입으세요.', '사람들과 적극적으로 소통하세요.', '예술 활동을 즐기세요.'],
'土': ['규칙적인 식사를 하세요.', '안정적인 계획을 세우세요.', '자연과 가까운 곳에 가세요.'],
'金': ['명확한 목표를 설정하세요.', '금속 액세서리를 착용하세요.', '원칙을 지키되 융통성도 발휘하세요.'],
'水': ['충분한 수분 섭취를 하세요.', '유연한 사고를 유지하세요.', '명상이나 요가로 마음을 다스리세요.']
};
advice.push(...adviceByElement[element]);
// 일반적인 조언
advice.push('자신의 장점을 살리고 단점을 보완하세요.');
advice.push('긍정적인 마인드로 하루를 시작하세요.');
return advice;
}
/**
* 전체 사주 해석 생성
*/
export function generateInterpretation(saju: SajuData): Interpretation {
const elements = analyzeElementBalance(saju);
const tenGods = analyzeTenGods(saju);
const personality = interpretDayStem(saju.day.stem, saju.day.element);
// 장점과 단점 분리
const strengths = personality.filter((_, i) => i < 3);
const weaknesses = [personality[3] || '균형 잡힌 성격입니다.'];
return {
personality,
strengths,
weaknesses,
career: interpretCareer(saju, tenGods),
relationships: interpretRelationships(saju, tenGods),
health: interpretHealth(saju, elements),
wealth: interpretWealth(saju, tenGods),
advice: generateAdvice(saju, elements)
};
}
/**
* 오행 균형 점수 계산
* 오행 비율(%) 계산
*/
export function calculateElementScore(saju: SajuData): { [key: string]: number } {
const elements = analyzeElementBalance(saju);
const total = Object.values(elements).reduce((a, b) => a + b, 0);
const balance = calculateDetailedElementBalance(saju);
const total = Object.values(balance).reduce((a, b) => a + b, 0);
const scores: { [key: string]: number } = {};
for (const [element, count] of Object.entries(elements)) {
scores[element] = Math.round((count / total) * 100);
for (const [element, value] of Object.entries(balance)) {
scores[element] = total > 0 ? Math.round((value / total) * 100) : 0;
}
return scores;
}
// ============================================================
// 신강/신약 자동 판단
// ============================================================
export interface DayMasterStrength {
result: '신강' | '신약' | '중화';
score: number;
reasons: string[];
}
const PRODUCE_MAP: { [key: string]: string } = {
'木': '火', '火': '土', '土': '金', '金': '水', '水': '木',
};
function getProducingElement(elem: string): string {
for (const [k, v] of Object.entries(PRODUCE_MAP)) {
if (v === elem) return k;
}
return '';
}
/**
* 신강/신약 판단
*/
export function analyzeDayMasterStrength(saju: SajuData): DayMasterStrength {
const dayStem = saju.dayStem;
const dayElement = FIVE_ELEMENTS[dayStem as keyof typeof FIVE_ELEMENTS];
const producingElement = getProducingElement(dayElement);
const reasons: string[] = [];
let score = 0;
// 1. 월령 득령 확인
const monthBranch = saju.month.branch;
const monthHidden = getHiddenStems(monthBranch);
const monthMainElement = FIVE_ELEMENTS[monthHidden[0] as keyof typeof FIVE_ELEMENTS];
if (monthMainElement === dayElement) {
score += 3;
reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간과 같은 ${FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR]}으로 강한 힘을 받음`);
} else if (monthMainElement === producingElement) {
score += 2;
reasons.push(`월령 득령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 생하는 ${FIVE_ELEMENTS_KR[producingElement as keyof typeof FIVE_ELEMENTS_KR]}으로 힘을 받음`);
} else {
score -= 2;
reasons.push(`월령 실령: 월지 ${saju.month.branchKr}(${monthBranch})의 본기가 일간을 돕지 않음`);
}
// 2. 통근 확인
const allBranches = [saju.year.branch, saju.month.branch, saju.day.branch];
if (saju.hour) allBranches.push(saju.hour.branch);
let rootCount = 0;
for (const branch of allBranches) {
const hidden = getHiddenStems(branch);
for (const h of hidden) {
const hElem = FIVE_ELEMENTS[h as keyof typeof FIVE_ELEMENTS];
if (hElem === dayElement || hElem === producingElement) {
rootCount++;
break;
}
}
}
if (rootCount >= 3) {
score += 2;
reasons.push(`통근 강함: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
} else if (rootCount >= 2) {
score += 1;
reasons.push(`통근 보통: ${rootCount}개 지지에서 일간의 뿌리를 찾음`);
} else {
score -= 1;
reasons.push(`통근 약함: ${rootCount}개 지지에서만 일간의 뿌리를 찾음`);
}
// 3. 투출 확인
const allStems = [saju.year.stem, saju.month.stem];
if (saju.hour) allStems.push(saju.hour.stem);
let helpingStemCount = 0;
for (const stem of allStems) {
const stemElem = FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS];
if (stemElem === dayElement || stemElem === producingElement) {
helpingStemCount++;
}
}
if (helpingStemCount >= 2) {
score += 2;
reasons.push(`투출 강함: 천간에 비겁/인성이 ${helpingStemCount}개 있어 일간을 도움`);
} else if (helpingStemCount === 1) {
score += 1;
reasons.push(`투출 보통: 천간에 비겁/인성이 1개 있음`);
} else {
score -= 1;
reasons.push(`투출 없음: 천간에 일간을 돕는 비겁/인성이 없음`);
}
// 4. 오행 비율 기반 조력 분석
const balance = calculateDetailedElementBalance(saju);
const helpingScore = balance[dayElement as keyof ElementBalance] + balance[producingElement as keyof ElementBalance];
const drainingScore = Object.entries(balance)
.filter(([k]) => k !== dayElement && k !== producingElement)
.reduce((sum, [, v]) => sum + v, 0);
if (helpingScore > drainingScore * 1.3) {
score += 1;
reasons.push(`오행 비율: 비겁+인성(${helpingScore.toFixed(1)}) > 식상+재관(${drainingScore.toFixed(1)}) → 일간 세력 우세`);
} else if (drainingScore > helpingScore * 1.3) {
score -= 1;
reasons.push(`오행 비율: 식상+재관(${drainingScore.toFixed(1)}) > 비겁+인성(${helpingScore.toFixed(1)}) → 일간 세력 열세`);
}
let result: '신강' | '신약' | '중화';
if (score >= 3) result = '신강';
else if (score <= -2) result = '신약';
else result = '중화';
return { result, score, reasons };
}
// ============================================================
// 용신 (用神) 추정
// ============================================================
export interface YongShinResult {
yongShin: string;
yongShinKr: string;
heeShin: string;
heeShinKr: string;
giShin: string;
giShinKr: string;
explanation: string;
}
const OVERCOME_MAP: { [key: string]: string } = {
'木': '土', '火': '金', '土': '水', '金': '木', '水': '火',
};
function getOvercomingMe(elem: string): string {
for (const [k, v] of Object.entries(OVERCOME_MAP)) {
if (v === elem) return k;
}
return '';
}
export function estimateYongShin(saju: SajuData, strength: DayMasterStrength): YongShinResult {
const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
const balance = calculateDetailedElementBalance(saju);
const producingMe = getProducingElement(dayElement); // 인성
const myProduct = PRODUCE_MAP[dayElement]; // 식상
const myOvercome = OVERCOME_MAP[dayElement]; // 재성
const overcomeMe = getOvercomingMe(dayElement); // 관살
const kr = (e: string) => FIVE_ELEMENTS_KR[e as keyof typeof FIVE_ELEMENTS_KR] || e;
if (strength.result === '신강') {
const candidates = [
{ elem: myProduct, score: balance[myProduct as keyof ElementBalance], name: '식상' },
{ elem: myOvercome, score: balance[myOvercome as keyof ElementBalance], name: '재성' },
{ elem: overcomeMe, score: balance[overcomeMe as keyof ElementBalance], name: '관살' },
];
candidates.sort((a, b) => a.score - b.score);
const yong = candidates[0];
const hee = candidates[1];
return {
yongShin: yong.elem, yongShinKr: kr(yong.elem),
heeShin: hee.elem, heeShinKr: kr(hee.elem),
giShin: dayElement, giShinKr: kr(dayElement),
explanation: `신강한 사주로 일간의 힘이 넘치므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 기운을 설기(泄氣)하거나 제어해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
};
} else if (strength.result === '신약') {
const candidates = [
{ elem: producingMe, score: balance[producingMe as keyof ElementBalance], name: '인성' },
{ elem: dayElement, score: balance[dayElement as keyof ElementBalance], name: '비겁' },
];
candidates.sort((a, b) => a.score - b.score);
const yong = candidates[0];
const hee = candidates[1];
return {
yongShin: yong.elem, yongShinKr: kr(yong.elem),
heeShin: hee.elem, heeShinKr: kr(hee.elem),
giShin: overcomeMe, giShinKr: kr(overcomeMe),
explanation: `신약한 사주로 일간의 힘이 부족하므로 ${yong.name}(${kr(yong.elem)}) 기운을 용신으로 삼아 일간을 돕고 힘을 보충해야 합니다. ${hee.name}(${kr(hee.elem)})이 희신으로 보조합니다.`,
};
} else {
const entries = Object.entries(balance) as [string, number][];
entries.sort((a, b) => a[1] - b[1]);
const yong = entries[0];
const hee = entries[1];
const gi = entries[entries.length - 1];
return {
yongShin: yong[0], yongShinKr: kr(yong[0]),
heeShin: hee[0], heeShinKr: kr(hee[0]),
giShin: gi[0], giShinKr: kr(gi[0]),
explanation: `중화에 가까운 사주로 오행이 비교적 균형을 이루고 있습니다. 가장 부족한 ${kr(yong[0])}(${yong[0]}) 기운을 보충하면 더욱 좋아집니다.`,
};
}
}
// ============================================================
// 세운 (歲運) 계산
// ============================================================
export interface SeunInfo {
stem: string;
branch: string;
stemKr: string;
branchKr: string;
element: string;
elementKr: string;
year: number;
interactions: BranchInteraction[];
}
export function calculateSeun(year: number, saju: SajuData): SeunInfo {
const ganzi = getYearGanzi(year);
const element = FIVE_ELEMENTS[ganzi.stem as keyof typeof FIVE_ELEMENTS];
const seunBranch = ganzi.branch;
const seunBranchKr = ganzi.branchKr;
const interactions: BranchInteraction[] = [];
const pillarBranches = [
{ branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
{ branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
{ branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
];
if (saju.hour) {
pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
}
const CHUNG: [string, string][] = [
['子', '午'], ['丑', '未'], ['寅', '申'], ['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
];
for (const [a, b] of CHUNG) {
for (const pb of pillarBranches) {
if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
interactions.push({
type: '충(沖)', branches: [seunBranch, pb.branch],
branchesKr: [seunBranchKr, pb.branchKr],
pillars: ['세운', pb.pillar],
description: `세운 ${seunBranchKr}${pb.pillar} ${pb.branchKr}가 충 → 해당 영역에 변동과 변화가 예상됨.`,
});
}
}
}
const YUKAP: [string, string, string][] = [
['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
];
for (const [a, b, elem] of YUKAP) {
for (const pb of pillarBranches) {
if ((seunBranch === a && pb.branch === b) || (seunBranch === b && pb.branch === a)) {
interactions.push({
type: '합(合)', branches: [seunBranch, pb.branch],
branchesKr: [seunBranchKr, pb.branchKr],
pillars: ['세운', pb.pillar],
description: `세운 ${seunBranchKr}${pb.pillar} ${pb.branchKr}가 합 → 해당 영역에 조화와 좋은 인연이 기대됨.`,
resultElement: elem,
});
}
}
}
return {
stem: ganzi.stem, branch: ganzi.branch,
stemKr: ganzi.stemKr, branchKr: ganzi.branchKr,
element, elementKr: FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR],
year, interactions,
};
}
// ============================================================
// 종합 분석 데이터 구조체
// ============================================================
export interface SajuAnalysis {
elementBalance: ElementBalance;
elementScores: { [key: string]: number };
dayMasterStrength: DayMasterStrength;
yongShin: YongShinResult;
branchInteractions: BranchInteraction[];
shinsal: Shinsal[];
gongmang: { branches: string[]; branchesKr: string[]; description: string };
seun: SeunInfo;
hiddenStems: { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[];
}
export function performFullAnalysis(saju: SajuData, currentYear: number = new Date().getFullYear()): SajuAnalysis {
const elementBalance = calculateDetailedElementBalance(saju);
const elementScores = calculateElementScore(saju);
const dayMasterStrength = analyzeDayMasterStrength(saju);
const yongShin = estimateYongShin(saju, dayMasterStrength);
const branchInteractions = analyzeBranchInteractions(saju);
const shinsal = calculateShinsal(saju);
const gongmang = calculateGongmang(saju.dayStem, saju.day.branch);
const seun = calculateSeun(currentYear, saju);
const hiddenStems = getAllHiddenStems(saju);
return {
elementBalance, elementScores, dayMasterStrength, yongShin,
branchInteractions, shinsal, gongmang, seun, hiddenStems,
};
}

43
lib/ensure-profile.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* 유저의 profiles 행이 존재하는지 확인하고, 없으면 생성한다.
* handle_new_user 트리거가 실패했거나 트리거 설정 전에 가입한 유저를 위한 안전장치.
*/
export async function ensureProfile(supabase: any, user: any): Promise<number> {
// 1. 프로필 조회
const { data: profile, error: selectError } = await supabase
.from('profiles')
.select('credits')
.eq('id', user.id)
.single();
if (profile) {
return profile.credits || 0;
}
// 2. 프로필이 없으면 생성
console.log('프로필이 없어서 자동 생성합니다:', user.id);
const { error: insertError } = await supabase
.from('profiles')
.insert({
id: user.id,
email: user.email,
full_name: user.user_metadata?.full_name || null,
avatar_url: user.user_metadata?.avatar_url || null,
credits: 0,
});
if (insertError) {
// 동시 생성 경쟁 시 중복 에러 무시하고 다시 조회
if (insertError.code === '23505') {
const { data: retryProfile } = await supabase
.from('profiles')
.select('credits')
.eq('id', user.id)
.single();
return retryProfile?.credits || 0;
}
console.error('프로필 생성 실패:', insertError);
}
return 0;
}

View File

@@ -1,10 +1,78 @@
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas';
/**
* html2canvas가 지원하지 않는 CSS 색상 함수(lab, oklch, oklab)를
* 클론된 문서에서 안전한 값으로 치환한다.
*
* 접근 방식:
* 1) <style> 태그의 텍스트에서 직접 regex 치환 (가장 확실)
* 2) CSSStyleSheet.cssRules에서 문제 속성 제거 (외부 스타일시트 대응)
* 3) 개별 요소의 인라인 style 정리
*/
function sanitizeUnsupportedColors(clonedDoc: Document) {
const unsafeRe = /(?:oklch|oklab|lab)\([^)]*\)/gi;
// 1단계: 모든 <style> 태그 텍스트에서 lab()/oklch()/oklab() → 안전한 색상으로 치환
const styleTags = clonedDoc.querySelectorAll('style');
styleTags.forEach((tag) => {
const text = tag.textContent;
if (text && unsafeRe.test(text)) {
tag.textContent = text.replace(unsafeRe, 'rgba(128,128,128,1)');
}
// reset lastIndex because we reuse the regex
unsafeRe.lastIndex = 0;
});
// 2단계: <link> 등 외부 스타일시트의 cssRules에서 문제 속성 제거
try {
for (const sheet of Array.from(clonedDoc.styleSheets)) {
try {
// <style> 태그는 이미 1단계에서 처리했으므로 skip
if (sheet.ownerNode && (sheet.ownerNode as HTMLElement).tagName === 'STYLE') continue;
const rules = sheet.cssRules;
if (!rules) continue;
for (let i = rules.length - 1; i >= 0; i--) {
const rule = rules[i];
if (rule instanceof CSSStyleRule) {
for (let j = rule.style.length - 1; j >= 0; j--) {
const prop = rule.style.item(j);
const val = rule.style.getPropertyValue(prop);
unsafeRe.lastIndex = 0;
if (unsafeRe.test(val)) {
rule.style.removeProperty(prop);
}
}
}
}
} catch {
// 크로스 오리진 스타일시트는 접근 불가 - 무시
}
}
} catch {
// styleSheets 접근 실패 시 무시
}
// 3단계: 인라인 style 속성에 남아있는 lab() 정리
const allElements = clonedDoc.querySelectorAll('*');
allElements.forEach((el) => {
const htmlEl = el as HTMLElement;
const inlineStyle = htmlEl.getAttribute('style');
if (inlineStyle) {
unsafeRe.lastIndex = 0;
if (unsafeRe.test(inlineStyle)) {
htmlEl.setAttribute(
'style',
inlineStyle.replace(unsafeRe, 'rgba(128,128,128,1)')
);
}
}
});
}
/**
* HTML 요소를 PDF로 변환하여 다운로드
* @param elementId - PDF로 변환할 HTML 요소의 ID
* @param filename - 다운로드될 PDF 파일명
*/
export async function downloadPDF(elementId: string, filename: string) {
try {
@@ -13,38 +81,33 @@ export async function downloadPDF(elementId: string, filename: string) {
throw new Error(`Element with id "${elementId}" not found`);
}
// 로딩 표시
const originalContent = element.innerHTML;
// HTML을 캔버스로 변환
const canvas = await html2canvas(element, {
scale: 2, // 해상도 향상
scale: 2,
useCORS: true,
logging: false,
backgroundColor: '#ffffff'
backgroundColor: '#ffffff',
onclone: (_doc: Document, clonedEl: HTMLElement) => {
sanitizeUnsupportedColors(clonedEl.ownerDocument);
}
});
// 캔버스를 이미지로 변환
const imgData = canvas.toDataURL('image/png');
// PDF 생성
const pdf = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4'
});
const imgWidth = 210; // A4 width in mm
const pageHeight = 297; // A4 height in mm
const imgWidth = 210;
const pageHeight = 297;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 0;
// 첫 페이지 추가
pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
// 여러 페이지가 필요한 경우
while (heightLeft > 0) {
position = heightLeft - imgHeight;
pdf.addPage();
@@ -52,9 +115,7 @@ export async function downloadPDF(elementId: string, filename: string) {
heightLeft -= pageHeight;
}
// PDF 다운로드
pdf.save(filename);
return true;
} catch (error) {
console.error('PDF 생성 중 오류 발생:', error);
@@ -63,10 +124,6 @@ export async function downloadPDF(elementId: string, filename: string) {
}
}
/**
* 현재 페이지를 PDF로 다운로드
* @param filename - 다운로드될 PDF 파일명
*/
export async function downloadCurrentPageAsPDF(filename: string) {
return downloadPDF('pdf-content', filename);
}

223
lib/saju-ai-prompt.ts Normal file
View File

@@ -0,0 +1,223 @@
import { SajuData, FIVE_ELEMENTS, FIVE_ELEMENTS_KR, HEAVENLY_STEMS_KR } from './saju-calculator';
import { DaeunPillar } from './daeun-calculator';
import { SajuAnalysis } from './ai-interpretation';
export function createSajuPrompt(
saju: SajuData,
currentDaeun: DaeunPillar | null,
gender: 'male' | 'female',
analysis: SajuAnalysis,
daeunList: DaeunPillar[] = []
): string {
const genderStr = gender === 'male' ? '남성' : '여성';
const birthDate = `${saju.birthDate.year}${saju.birthDate.month}${saju.birthDate.day}${saju.birthDate.hour ? saju.birthDate.hour + '시' : '시간 모름'}`;
const dayStemKr = saju.day.stemKr;
const dayElement = FIVE_ELEMENTS[saju.dayStem as keyof typeof FIVE_ELEMENTS];
const dayElementKr = FIVE_ELEMENTS_KR[dayElement as keyof typeof FIVE_ELEMENTS_KR];
// ── 사주 원국 ──
const pillars = [
`년주: ${saju.year.stem}${saju.year.branch} (${saju.year.stemKr}${saju.year.branchKr}) | 천간십성: ${saju.year.tenGod} | 십이운성: ${saju.year.fortune}`,
`월주: ${saju.month.stem}${saju.month.branch} (${saju.month.stemKr}${saju.month.branchKr}) | 천간십성: ${saju.month.tenGod} | 십이운성: ${saju.month.fortune}`,
`일주: ${saju.day.stem}${saju.day.branch} (${saju.day.stemKr}${saju.day.branchKr}) | 일간(日干) | 십이운성: ${saju.day.fortune}`,
saju.hour
? `시주: ${saju.hour.stem}${saju.hour.branch} (${saju.hour.stemKr}${saju.hour.branchKr}) | 천간십성: ${saju.hour.tenGod} | 십이운성: ${saju.hour.fortune}`
: '시주: 정보 없음',
].join('\n');
// ── 지장간 ──
const hiddenStemsStr = analysis.hiddenStems.map(h => {
const stemsDetail = h.stems.map(s => `${s.stemKr}(${s.stem}, ${FIVE_ELEMENTS_KR[s.element as keyof typeof FIVE_ELEMENTS_KR]}, ${s.role})`).join(', ');
return `${h.pillar} ${h.branchKr}(${h.branch}): [${stemsDetail}]`;
}).join('\n');
// ── 오행 분석 ──
const eb = analysis.elementBalance;
const es = analysis.elementScores;
const elementStr = Object.entries(eb).map(([k, v]) => {
return `${FIVE_ELEMENTS_KR[k as keyof typeof FIVE_ELEMENTS_KR]}(${k}): ${v}점 (${es[k]}%)`;
}).join(' | ');
// ── 신강/신약 ──
const strength = analysis.dayMasterStrength;
const strengthStr = `판정: ${strength.result} (점수: ${strength.score})\n근거:\n${strength.reasons.map(r => `- ${r}`).join('\n')}`;
// ── 용신/희신/기신 ──
const ys = analysis.yongShin;
const yongShinStr = `용신: ${ys.yongShinKr}(${ys.yongShin}) | 희신: ${ys.heeShinKr}(${ys.heeShin}) | 기신: ${ys.giShinKr}(${ys.giShin})\n설명: ${ys.explanation}`;
// ── 지지 상호작용 ──
const interactionsStr = analysis.branchInteractions.length > 0
? analysis.branchInteractions.map(i => `- ${i.type}: ${i.branchesKr.join('')} (${i.pillars.join('↔')}) → ${i.description}`).join('\n')
: '- 특별한 합/충/형/파/해 없음';
// ── 신살 ──
const shinsalStr = analysis.shinsal.length > 0
? analysis.shinsal.map(s => `- ${s.name}(${s.nameHanja}): ${s.pillar} ${s.branchKr}(${s.branch}) → ${s.description}`).join('\n')
: '- 특별한 신살 없음';
// ── 공망 ──
const gongmangStr = analysis.gongmang.description;
// ── 세운 ──
const seun = analysis.seun;
const seunStr = `${seun.year}${seun.stemKr}${seun.branchKr}(${seun.stem}${seun.branch})년 | 오행: ${seun.elementKr}(${seun.element})`;
const seunInteractions = seun.interactions.length > 0
? seun.interactions.map(i => `- ${i.type}: ${i.description}`).join('\n')
: '- 세운과 원국 사이에 특별한 충/합 없음';
// ── 대운 ──
const daeunInfo = currentDaeun
? `현재 대운: ${currentDaeun.stemKr}${currentDaeun.branchKr}(${currentDaeun.stem}${currentDaeun.branch}) 대운 | ${currentDaeun.age}세~${currentDaeun.age + 9}세 (${currentDaeun.startYear}~${currentDaeun.endYear}년)`
: '현재 대운 정보 없음';
const allDaeunStr = daeunList.length > 0
? daeunList.map(d => `${d.stemKr}${d.branchKr}(${d.age}세~${d.age + 9}세, ${d.startYear}~${d.endYear}년)`).join(' → ')
: '';
const systemPrompt = `당신은 따뜻하고 유머러스한 사주 상담사예요. 마치 오랜 친구처럼 편하게, 하지만 놀라울 정도로 정확하게 사주를 읽어주는 사람이에요. 딱딱한 전문 용어 대신 비유와 이야기로 풀어내는 게 당신의 스타일이에요.
[핵심 원칙 - 반드시 지켜주세요]
- 아래 제공된 계산 데이터를 바탕으로 해석하되, 전문 용어는 최소화하고 비유와 스토리텔링으로 풀어주세요.
- "~요" 체의 친근한 말투를 사용하세요. (예: "~이에요", "~거든요", "~잖아요", "~인 거죠")
- 각 섹션 제목은 창의적인 비유나 은유를 사용한 감성적 제목으로 만드세요. (예: "얼음 속에 숨겨진 불꽃", "당신 안의 숨은 보석")
- 사주 데이터에 근거하되, "당신은 마치 ~같은 사람이에요"처럼 생생한 비유로 설명하세요.
- 때로는 따끔한 조언도 섞어주세요. 친구가 해주는 솔직한 충고처럼요. (예: "솔직히 말하면... 그거 완벽주의 아니고 그냥 겁이 많은 거예요 😅")
- 각 항목 최소 5~8문장으로 깊이 있게, 하지만 술술 읽히게 작성하세요.
- 이 사람만을 위한 개인화된 분석이어야 해요. 일반론 절대 금지!
- 중간중간 공감 포인트를 넣어주세요. (예: "혹시 이런 경험 있지 않나요?", "맞죠?")
- 마지막에 진심 어린 응원 한마디를 꼭 넣어주세요.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[사용자 정보]
- 성별: ${genderStr}
- 생년월일시: ${birthDate}
- 일간: ${dayStemKr}(${saju.dayStem}) → ${dayElementKr}(${dayElement})
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[사주 원국]
${pillars}
[지장간]
${hiddenStemsStr}
[오행 점수 (가중치 적용)]
${elementStr}
총점: ${Object.values(eb).reduce((a, b) => a + b, 0).toFixed(1)}
[신강/신약]
${strengthStr}
[용신/희신/기신]
${yongShinStr}
[지지 상호작용]
${interactionsStr}
[신살]
${shinsalStr}
[공망]
${gongmangStr}
[대운]
${daeunInfo}
전체 흐름: ${allDaeunStr}
[세운 - 올해]
${seunStr}
${seunInteractions}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[분석 요구사항 - 12개 항목]
위 데이터를 바탕으로 아래 12개 항목을 작성하세요.
각 항목은 반드시 "## " 로 시작하는 헤더를 사용하세요.
헤더 제목은 번호 + 창의적인 비유/은유 제목으로 만드세요. (아래는 예시일 뿐, 사주 내용에 맞게 자유롭게 창작하세요)
## 1. [타고난 기질 - 창의적 제목]
예시 제목: "차가운 호수 아래 숨겨진 용의 심장" / "봄바람처럼 자유로운 영혼"
- ${dayStemKr}${saju.day.branchKr}일주의 핵심 성격을 비유로 풀어주세요
- "당신은 마치 ~같은 사람이에요" 패턴 활용
- 겉으로 보이는 모습 vs 진짜 내면을 대비시켜 흥미롭게
- 강점은 확 칭찬하고, 약점은 "솔직히 말하면..." 패턴으로 따끔하지만 사랑스럽게
## 2. [오행 밸런스 & 개운법 - 창의적 제목]
예시 제목: "당신에게 부족한 한 조각, 그걸 채우는 법" / "운을 끌어당기는 나만의 비밀 무기"
- 오행 데이터를 인용하되, "당신의 에너지 밸런스를 보면..." 식으로 쉽게
- 용신(${ys.yongShinKr}) 기운을 강화하는 실생활 팁: 색상, 방향, 숫자, 음식, 행동
- 기신(${ys.giShinKr}) 기운 피하는 법도 구체적으로
- "오늘부터 당장 ~해보세요!" 같은 실천 가능한 조언
## 3. [지지 상호작용 - 창의적 제목]
예시 제목: "당신 안에서 벌어지는 보이지 않는 전쟁" / "운명이 엮어준 특별한 인연의 실타래"
- 합/충/형 데이터를 바탕으로 실생활 영향을 이야기로 풀어주세요
- 어려운 용어 대신 "쉽게 말하면..." 패턴 활용
## 4. [신살의 영향 - 창의적 제목]
예시 제목: "당신이 타고난 숨겨진 초능력" / "조심해야 할 함정, 그리고 날개"
- 각 신살을 흥미로운 비유로 설명 (역마살 → "여행자의 별", 도화살 → "매력의 별" 등)
- 긍정 신살은 신나게, 주의 신살은 걱정 말라는 톤으로
## 5. [재물운 - 창의적 제목]
예시 제목: "돈이 당신을 찾아오는 방식" / "통장이 웃는 시기, 우는 시기"
- 편재/정재 위치와 강도를 쉬운 비유로
- 돈 버는 스타일 (한방 vs 꾸준히 vs 투자형 등)
- 주의할 시기와 기회의 시기를 구체적으로
## 6. [직업 적성 - 창의적 제목]
예시 제목: "당신이 빛나는 무대는 따로 있어요" / "타고난 프로의 DNA"
- 적합한 분야를 구체적으로 추천 (추상적 말고 직업명까지)
- 조직형 vs 프리랜서/사업형 판단
- ${genderStr}의 특성 고려
## 7. [애정운 - 창의적 제목]
예시 제목: "사랑이 찾아오는 계절" / "당신의 이상형, 사주가 말해주는 진짜 궁합"
- ${genderStr === '남성' ? '재성' : '관성'} 기반 배우자 복 분석을 로맨틱하게
- 연애 스타일, 배우자 상을 재미있게 묘사
- 결혼 적령기를 부드럽게 안내
## 8. [건강운 - 창의적 제목]
예시 제목: "몸이 보내는 작은 신호들" / "100세까지 건강한 나를 위한 처방전"
- 오행 과부족 → 주의할 건강 포인트를 걱정 안 되게 부드럽게
- 구체적인 생활 습관 조언 (음식, 운동, 스트레스 관리)
## 9. [현재 대운 - 창의적 제목]
예시 제목: "지금 당신 앞에 펼쳐진 10년의 지도" / "인생의 봄이 오고 있어요"
- ${daeunInfo}를 바탕으로 현재 10년의 의미를 이야기로
- 지금 집중해야 할 것, 조심할 것을 친구처럼 조언
## 10. [올해의 운세 - 창의적 제목] (${seun.year}년)
예시 제목: "올해, 당신에게 찾아올 세 가지 기회" / "${seun.year}년은 당신의 해예요"
- 세운 데이터 바탕으로 올해 키워드를 뽑아 설명
- 상반기 vs 하반기 흐름
- "이것만은 꼭!" 하는 핵심 조언
## 11. [인생의 황금기 - 창의적 제목]
예시 제목: "인생에서 가장 빛나는 순간이 다가오고 있어요" / "대박 터지는 그 시기"
- 전체 대운 흐름에서 최고의 시기를 콕 집어서
- 그 시기에 어떤 기회가 오는지 구체적이고 설레게
- "그때를 위해 지금 준비할 것" 조언
## 12. [종합 조언 - 창의적 제목]
예시 제목: "당신이라는 별에게 보내는 편지" / "마지막으로 꼭 전하고 싶은 말"
- 이 사주의 핵심 강점과 약점을 한 문장으로 요약
- 용신(${ys.yongShinKr}) 활용 일상 팁
- 진심 어린 응원과 철학적 메시지로 마무리
- 마지막 문장은 감동적인 한 줄로 끝내주세요
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[톤앤매너 - 가장 중요!!]
- "~요" 체 친근한 말투 (절대 "~이다/한다" 체 사용 금지)
- 전문 용어 최소화. 꼭 필요하면 비유로 풀어서 설명
- 비유와 은유를 적극 활용 ("마치 ~처럼", "당신은 ~같은 사람이에요")
- 중간중간 이모지를 자연스럽게 사용 (과하지 않게, 섹션당 1~2개)
- 따끔한 조언 + 따뜻한 응원의 밸런스
- "혹시 ~한 적 있지 않나요?" 같은 공감형 질문으로 몰입감 유도
- Markdown 형식: ## 헤더, **볼드**, 리스트 활용
- 각 섹션 제목은 반드시 번호 포함 (## 1. ~ ## 12.)
- 읽는 사람이 "와, 이거 진짜 내 얘기다!" 하고 느끼게 만들어주세요`;
return systemPrompt;
}

View File

@@ -236,13 +236,13 @@ export function calculateSaju(
year: {
...yearGanzi,
element: FIVE_ELEMENTS[yearGanzi.stem as keyof typeof FIVE_ELEMENTS],
tenGod: getTenGod(dayStem, yearGanzi.stem, HEAVENLY_STEMS.indexOf(yearGanzi.stem as any) % 2 === isDayYang ? true : false),
tenGod: getTenGod(dayStem, yearGanzi.stem, (HEAVENLY_STEMS.indexOf(yearGanzi.stem as any) % 2 === 0) === isDayYang),
fortune: getTwelveFortune(dayStem, yearGanzi.branch)
},
month: {
...monthGanzi,
element: FIVE_ELEMENTS[monthGanzi.stem as keyof typeof FIVE_ELEMENTS],
tenGod: getTenGod(dayStem, monthGanzi.stem, HEAVENLY_STEMS.indexOf(monthGanzi.stem as any) % 2 === isDayYang ? true : false),
tenGod: getTenGod(dayStem, monthGanzi.stem, (HEAVENLY_STEMS.indexOf(monthGanzi.stem as any) % 2 === 0) === isDayYang),
fortune: getTwelveFortune(dayStem, monthGanzi.branch)
},
day: {
@@ -260,10 +260,465 @@ export function calculateSaju(
result.hour = {
...hourGanzi,
element: FIVE_ELEMENTS[hourGanzi.stem as keyof typeof FIVE_ELEMENTS],
tenGod: getTenGod(dayStem, hourGanzi.stem, HEAVENLY_STEMS.indexOf(hourGanzi.stem as any) % 2 === isDayYang ? true : false),
tenGod: getTenGod(dayStem, hourGanzi.stem, (HEAVENLY_STEMS.indexOf(hourGanzi.stem as any) % 2 === 0) === isDayYang),
fortune: getTwelveFortune(dayStem, hourGanzi.branch)
};
}
return result;
}
// ============================================================
// 지장간 (藏干) - 각 지지에 숨어있는 천간
// ============================================================
export const HIDDEN_STEMS: { [key: string]: string[] } = {
'子': ['癸'],
'丑': ['己', '癸', '辛'],
'寅': ['甲', '丙', '戊'],
'卯': ['乙'],
'辰': ['戊', '乙', '癸'],
'巳': ['丙', '庚', '戊'],
'午': ['丁', '己'],
'未': ['己', '丁', '乙'],
'申': ['庚', '壬', '戊'],
'酉': ['辛'],
'戌': ['戊', '辛', '丁'],
'亥': ['壬', '甲'],
};
/**
* 지지의 지장간(숨은 천간) 반환
*/
export function getHiddenStems(branch: string): string[] {
return HIDDEN_STEMS[branch] || [];
}
/**
* 4주 전체의 지장간 정보 반환
*/
export function getAllHiddenStems(saju: SajuData): { pillar: string; branch: string; branchKr: string; stems: { stem: string; stemKr: string; element: string; role: string }[] }[] {
const pillars = [
{ pillar: '년주', branch: saju.year.branch, branchKr: saju.year.branchKr },
{ pillar: '월주', branch: saju.month.branch, branchKr: saju.month.branchKr },
{ pillar: '일주', branch: saju.day.branch, branchKr: saju.day.branchKr },
];
if (saju.hour) {
pillars.push({ pillar: '시주', branch: saju.hour.branch, branchKr: saju.hour.branchKr });
}
return pillars.map(p => {
const hidden = getHiddenStems(p.branch);
return {
...p,
stems: hidden.map((stem, idx) => {
const stemIndex = HEAVENLY_STEMS.indexOf(stem as any);
const role = idx === 0 ? '정기(본기)' : idx === 1 ? '중기' : '여기';
return {
stem,
stemKr: HEAVENLY_STEMS_KR[stemIndex],
element: FIVE_ELEMENTS[stem as keyof typeof FIVE_ELEMENTS],
role,
};
}),
};
});
}
// ============================================================
// 지지 상호작용 (合/沖/刑/破/害)
// ============================================================
export interface BranchInteraction {
type: string; // 육합, 삼합, 방합, 충, 형, 파, 해
branches: string[]; // 관련 지지 (한자)
branchesKr: string[]; // 관련 지지 (한글)
pillars: string[]; // 관련 기둥 (년주, 월주 등)
description: string;
resultElement?: string; // 합의 결과 오행 (해당 시)
}
const YUKAP_PAIRS: [string, string, string][] = [
['子', '丑', '土'], ['寅', '亥', '木'], ['卯', '戌', '火'],
['辰', '酉', '金'], ['巳', '申', '水'], ['午', '未', '火'],
];
const SAMHAP_GROUPS: [string, string, string, string][] = [
['申', '子', '辰', '水'], ['亥', '卯', '未', '木'],
['寅', '午', '戌', '火'], ['巳', '酉', '丑', '金'],
];
const BANGHAP_GROUPS: [string, string, string, string][] = [
['寅', '卯', '辰', '木'], ['巳', '午', '未', '火'],
['申', '酉', '戌', '金'], ['亥', '子', '丑', '水'],
];
const CHUNG_PAIRS: [string, string][] = [
['子', '午'], ['丑', '未'], ['寅', '申'],
['卯', '酉'], ['辰', '戌'], ['巳', '亥'],
];
const HYUNG_GROUPS: { branches: string[]; name: string }[] = [
{ branches: ['寅', '巳', '申'], name: '무은지형(無恩之刑)' },
{ branches: ['丑', '戌', '未'], name: '지세지형(恃勢之刑)' },
{ branches: ['子', '卯'], name: '무례지형(無禮之刑)' },
];
const JAHYUNG_BRANCHES = ['辰', '午', '酉', '亥'];
const PA_PAIRS: [string, string][] = [
['子', '酉'], ['丑', '辰'], ['寅', '亥'],
['卯', '午'], ['巳', '申'], ['未', '戌'],
];
const HAE_PAIRS: [string, string][] = [
['子', '未'], ['丑', '午'], ['寅', '巳'],
['卯', '辰'], ['申', '亥'], ['酉', '戌'],
];
const ELEMENT_NAMES_KR: { [key: string]: string } = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };
/**
* 지지 상호작용 분석
*/
export function analyzeBranchInteractions(saju: SajuData): BranchInteraction[] {
const interactions: BranchInteraction[] = [];
// 기둥별 지지 수집
const pillarBranches: { branch: string; pillar: string; branchKr: string }[] = [
{ branch: saju.year.branch, pillar: '년주', branchKr: saju.year.branchKr },
{ branch: saju.month.branch, pillar: '월주', branchKr: saju.month.branchKr },
{ branch: saju.day.branch, pillar: '일주', branchKr: saju.day.branchKr },
];
if (saju.hour) {
pillarBranches.push({ branch: saju.hour.branch, pillar: '시주', branchKr: saju.hour.branchKr });
}
const branches = pillarBranches.map(p => p.branch);
// 육합 (六合) 검사
for (const [a, b, elem] of YUKAP_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '육합(六合)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 육합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 기운 생성. 조화와 화합의 관계.`,
resultElement: elem,
});
}
}
// 삼합 (三合) 검사
for (const [a, b, c, elem] of SAMHAP_GROUPS) {
const found = [a, b, c].filter(x => branches.includes(x));
if (found.length >= 2) {
const foundPillars = found.map(x => {
const idx = branches.indexOf(x);
return pillarBranches[idx];
});
const isComplete = found.length === 3;
interactions.push({
type: isComplete ? '삼합(三合)' : '반삼합(半三合)',
branches: found,
branchesKr: foundPillars.map(p => p.branchKr),
pillars: foundPillars.map(p => p.pillar),
description: `${foundPillars.map(p => p.branchKr).join('')} ${isComplete ? '삼합' : '반삼합'}${ELEMENT_NAMES_KR[elem]}(${elem})국. ${isComplete ? '강력한 합의 기운.' : '삼합의 기운이 부분적으로 작용.'}`,
resultElement: elem,
});
}
}
// 방합 (方合) 검사
for (const [a, b, c, elem] of BANGHAP_GROUPS) {
const found = [a, b, c].filter(x => branches.includes(x));
if (found.length === 3) {
const foundPillars = found.map(x => {
const idx = branches.indexOf(x);
return pillarBranches[idx];
});
interactions.push({
type: '방합(方合)',
branches: found,
branchesKr: foundPillars.map(p => p.branchKr),
pillars: foundPillars.map(p => p.pillar),
description: `${foundPillars.map(p => p.branchKr).join('')} 방합 → ${ELEMENT_NAMES_KR[elem]}(${elem}) 방국. 매우 강한 오행 기운.`,
resultElement: elem,
});
}
}
// 충 (沖) 검사
for (const [a, b] of CHUNG_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '충(沖)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 충 → 변동, 갈등, 변화의 에너지. ${pillarBranches[idxA].pillar}${pillarBranches[idxB].pillar} 사이의 긴장 관계.`,
});
}
}
// 형 (刑) 검사
for (const group of HYUNG_GROUPS) {
const found = group.branches.filter(x => branches.includes(x));
if (found.length >= 2) {
const foundPillars = found.map(x => {
const idx = branches.indexOf(x);
return pillarBranches[idx];
});
interactions.push({
type: '형(刑)',
branches: found,
branchesKr: foundPillars.map(p => p.branchKr),
pillars: foundPillars.map(p => p.pillar),
description: `${foundPillars.map(p => p.branchKr).join('')} ${group.name} → 시련과 갈등의 기운. 주의가 필요한 관계.`,
});
}
}
// 자형 (自刑) 검사
for (const jb of JAHYUNG_BRANCHES) {
const count = branches.filter(x => x === jb).length;
if (count >= 2) {
const brKr = EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(jb as any)];
interactions.push({
type: '자형(自刑)',
branches: [jb, jb],
branchesKr: [brKr, brKr],
pillars: pillarBranches.filter(p => p.branch === jb).map(p => p.pillar),
description: `${brKr}${brKr} 자형 → 자기 자신과의 갈등, 내면의 갈등 기운.`,
});
}
}
// 파 (破) 검사
for (const [a, b] of PA_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '파(破)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 파 → 관계의 균열, 계획의 차질 가능성.`,
});
}
}
// 해 (害) 검사
for (const [a, b] of HAE_PAIRS) {
const idxA = branches.indexOf(a);
const idxB = branches.indexOf(b);
if (idxA !== -1 && idxB !== -1) {
interactions.push({
type: '해(害)',
branches: [a, b],
branchesKr: [pillarBranches[idxA].branchKr, pillarBranches[idxB].branchKr],
pillars: [pillarBranches[idxA].pillar, pillarBranches[idxB].pillar],
description: `${pillarBranches[idxA].branchKr}${pillarBranches[idxB].branchKr} 해 → 은근한 방해, 원망의 기운.`,
});
}
}
return interactions;
}
// ============================================================
// 신살 (神煞) 계산
// ============================================================
export interface Shinsal {
name: string;
nameHanja: string;
branch: string;
branchKr: string;
pillar: string;
description: string;
}
// 일지 삼합국 기준 신살 매핑
const SAMHAP_GROUP_MAP: { [key: string]: string } = {
'申': '申子辰', '子': '申子辰', '辰': '申子辰',
'寅': '寅午戌', '午': '寅午戌', '戌': '寅午戌',
'巳': '巳酉丑', '酉': '巳酉丑', '丑': '巳酉丑',
'亥': '亥卯未', '卯': '亥卯未', '未': '亥卯未',
};
const YEOKMA_MAP: { [key: string]: string } = {
'申子辰': '寅', '寅午戌': '申', '巳酉丑': '亥', '亥卯未': '巳',
};
const DOHWA_MAP: { [key: string]: string } = {
'申子辰': '酉', '寅午戌': '卯', '巳酉丑': '午', '亥卯未': '子',
};
const HWAGAE_MAP: { [key: string]: string } = {
'申子辰': '辰', '寅午戌': '戌', '巳酉丑': '丑', '亥卯未': '未',
};
// 천을귀인 (天乙貴人) - 일간 기준
const CHEONUL_MAP: { [key: string]: string[] } = {
'甲': ['丑', '未'], '乙': ['子', '申'], '丙': ['亥', '酉'], '丁': ['亥', '酉'],
'戊': ['丑', '未'], '己': ['子', '申'], '庚': ['丑', '未'], '辛': ['寅', '午'],
'壬': ['卯', '巳'], '癸': ['卯', '巳'],
};
// 문창귀인 (文昌貴人) - 일간 기준
const MUNCHANG_MAP: { [key: string]: string } = {
'甲': '巳', '乙': '午', '丙': '申', '丁': '酉',
'戊': '申', '己': '酉', '庚': '亥', '辛': '子',
'壬': '寅', '癸': '卯',
};
// 천덕귀인 (天德貴人) - 월지 기준
const CHEONDUK_MAP: { [key: string]: string } = {
'寅': '丁', '卯': '申', '辰': '壬', '巳': '辛',
'午': '亥', '未': '甲', '申': '癸', '酉': '寅',
'戌': '丙', '亥': '乙', '子': '巳', '丑': '庚',
};
/**
* 신살 계산
*/
export function calculateShinsal(saju: SajuData): Shinsal[] {
const result: Shinsal[] = [];
const dayBranch = saju.day.branch;
const dayStem = saju.dayStem;
const monthBranch = saju.month.branch;
// 4주의 지지 수집
const pillarBranches: { branch: string; branchKr: string; pillar: string }[] = [
{ branch: saju.year.branch, branchKr: saju.year.branchKr, pillar: '년주' },
{ branch: saju.month.branch, branchKr: saju.month.branchKr, pillar: '월주' },
{ branch: saju.day.branch, branchKr: saju.day.branchKr, pillar: '일주' },
];
if (saju.hour) {
pillarBranches.push({ branch: saju.hour.branch, branchKr: saju.hour.branchKr, pillar: '시주' });
}
const group = SAMHAP_GROUP_MAP[dayBranch];
// 역마살
if (group) {
const yeokma = YEOKMA_MAP[group];
for (const pb of pillarBranches) {
if (pb.branch === yeokma && pb.pillar !== '일주') {
result.push({
name: '역마살', nameHanja: '驛馬殺', branch: yeokma,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '이동, 변동, 해외, 출장이 많은 기운. 활동적이고 한 곳에 머물지 못하는 성향.',
});
}
}
// 도화살
const dohwa = DOHWA_MAP[group];
for (const pb of pillarBranches) {
if (pb.branch === dohwa && pb.pillar !== '일주') {
result.push({
name: '도화살', nameHanja: '桃花殺', branch: dohwa,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '매력, 인기, 예술적 감각. 이성에게 끌리는 기운이 강하며 대인관계가 화려함.',
});
}
}
// 화개살
const hwagae = HWAGAE_MAP[group];
for (const pb of pillarBranches) {
if (pb.branch === hwagae && pb.pillar !== '일주') {
result.push({
name: '화개살', nameHanja: '華蓋殺', branch: hwagae,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '학문, 종교, 예술에 심취하는 기운. 고독을 즐기며 정신적 세계에 몰두하는 성향.',
});
}
}
}
// 천을귀인
const cheonulBranches = CHEONUL_MAP[dayStem] || [];
for (const pb of pillarBranches) {
if (cheonulBranches.includes(pb.branch) && pb.pillar !== '일주') {
result.push({
name: '천을귀인', nameHanja: '天乙貴人', branch: pb.branch,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '위기에서 귀인의 도움을 받는 길한 기운. 어려울 때 도움을 주는 사람이 나타남.',
});
}
}
// 문창귀인
const munchangBranch = MUNCHANG_MAP[dayStem];
if (munchangBranch) {
for (const pb of pillarBranches) {
if (pb.branch === munchangBranch && pb.pillar !== '일주') {
result.push({
name: '문창귀인', nameHanja: '文昌貴人', branch: pb.branch,
branchKr: pb.branchKr, pillar: pb.pillar,
description: '학문, 시험, 문서에 유리한 기운. 공부를 잘하며 시험운이 좋음.',
});
}
}
}
// 천덕귀인 (월지 기준, 천간에서 확인)
const cheondukStem = CHEONDUK_MAP[monthBranch];
if (cheondukStem) {
const allStems = [
{ stem: saju.year.stem, pillar: '년주' },
{ stem: saju.day.stem, pillar: '일주' },
];
if (saju.hour) allStems.push({ stem: saju.hour.stem, pillar: '시주' });
for (const ps of allStems) {
if (ps.stem === cheondukStem) {
const stemIdx = HEAVENLY_STEMS.indexOf(ps.stem as any);
result.push({
name: '천덕귀인', nameHanja: '天德貴人', branch: monthBranch,
branchKr: EARTHLY_BRANCHES_KR[EARTHLY_BRANCHES.indexOf(monthBranch as any)],
pillar: ps.pillar,
description: '하늘의 덕을 받는 기운. 재난을 피하고 복을 받는 길신 중의 길신.',
});
}
}
}
return result;
}
// ============================================================
// 공망 (空亡) 계산
// ============================================================
/**
* 60갑자에서 일주의 순(旬)을 찾아 공망 지지 2개를 반환
*/
export function calculateGongmang(dayStem: string, dayBranch: string): { branches: string[]; branchesKr: string[]; description: string } {
const stemIdx = HEAVENLY_STEMS.indexOf(dayStem as any);
const branchIdx = EARTHLY_BRANCHES.indexOf(dayBranch as any);
// 60갑자에서 해당 순(旬)의 시작점 = 천간이 甲인 지점
// 순의 시작 지지 인덱스 = (branchIdx - stemIdx + 12) % 12
const startBranchIdx = (branchIdx - stemIdx + 120) % 12;
// 공망 = 순에 포함되지 않는 2개의 지지
// 순은 10개의 간지 → 10개의 지지 사용, 2개가 남음
const gongmang1Idx = (startBranchIdx + 10) % 12;
const gongmang2Idx = (startBranchIdx + 11) % 12;
const branch1 = EARTHLY_BRANCHES[gongmang1Idx];
const branch2 = EARTHLY_BRANCHES[gongmang2Idx];
const branchKr1 = EARTHLY_BRANCHES_KR[gongmang1Idx];
const branchKr2 = EARTHLY_BRANCHES_KR[gongmang2Idx];
return {
branches: [branch1, branch2],
branchesKr: [branchKr1, branchKr2],
description: `${branchKr1}(${branch1}${branchKr2}(${branch2}) 공망 → 해당 지지의 기운이 비어있어 허무하거나 집착이 없는 영역. 오히려 초월적 능력이 될 수 있음.`,
};
}

9
lib/supabase/client.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}

30
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,30 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
)
}

7
lib/supabaseClient.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '';
export const supabase = createClient(supabaseUrl, supabaseAnonKey);