T6: offer True Citra and Fagan-Bradley beside Raman/Lahiri/KP/True Pushya,
engine-supported values only; widen profiles_ayanamsa_check by an additive
migration; ephemeris note names; path tests so no new value falls back to
Raman.
T7: the unlayered `button { color: inherit; }` beats Tailwind's
text-primary-foreground, so solid report buttons showed page ink (light
(light 4.85:1, dark 5.77:1), grey disabled without fade, neutral selected
preset. button.tsx and theme tokens unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
499 lines
18 KiB
TypeScript
499 lines
18 KiB
TypeScript
import type {
|
||
EphemerisEventItem,
|
||
EphemerisEvents,
|
||
EphemerisPanchangaFactor,
|
||
EphemerisQuality,
|
||
EphemerisTransitBody,
|
||
} from "@/lib/ephemeris-contract";
|
||
import { isAyanamsaName, type AyanamsaName } from "@/lib/ayanamsa";
|
||
import { normalizePanchangaName, panchangaNameZh, splitTithiName } from "@/lib/panchanga-names-zh";
|
||
|
||
export const EPHEMERIS_COPY = {
|
||
title: "星历",
|
||
back: "← 对话",
|
||
previousDay: "前一天",
|
||
nextDay: "后一天",
|
||
today: "今天",
|
||
panchangaHeading: "今日五要素",
|
||
// D10: the chart page's name is filled from the subject's own setting, not
|
||
// hard-coded; see `ephemerisAyanamsaNote`.
|
||
panchangaAyanamsaNoteDifferent: "五要素按 Lahiri 岁差算,和星盘页的 {chart} 不同。",
|
||
panchangaAyanamsaNoteSame: "五要素按 Lahiri 岁差算,和星盘页相同。",
|
||
panchangaAyanamsaNoteUnknown: "五要素按 Lahiri 岁差算。",
|
||
panchangaUnavailable: "这一天的五要素还没拿到。",
|
||
transitsHeading: "当日行运",
|
||
transitsUnavailable: "这一天的行运还没拿到。",
|
||
natalMissing: "还没有本命上升,先不写落在你的第几宫。",
|
||
eventsHeading: "未来九十天",
|
||
eventsPending: "换座和停滞还在取。",
|
||
eventsUnavailable: "换座和停滞还没接上。这一页其余部分不受影响。",
|
||
dateFailed: "这一天的星历没取到,点日期再试一次。",
|
||
ask: "带这天去提问",
|
||
askPrompt: "想问这一天适不适合做某件事?",
|
||
loginTitle: "请先登录",
|
||
loginBody: "登录后即可查看星历。",
|
||
loginAction: "去登录",
|
||
retrograde: "逆行",
|
||
} as const;
|
||
|
||
export const EPHEMERIS_PLANET_ORDER = [
|
||
"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu",
|
||
] as const;
|
||
|
||
const SIGNS = [
|
||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||
] as const;
|
||
|
||
const SIGN_ZH: Record<(typeof SIGNS)[number], string> = {
|
||
Aries: "白羊座",
|
||
Taurus: "金牛座",
|
||
Gemini: "双子座",
|
||
Cancer: "巨蟹座",
|
||
Leo: "狮子座",
|
||
Virgo: "处女座",
|
||
Libra: "天秤座",
|
||
Scorpio: "天蝎座",
|
||
Sagittarius: "射手座",
|
||
Capricorn: "摩羯座",
|
||
Aquarius: "水瓶座",
|
||
Pisces: "双鱼座",
|
||
};
|
||
|
||
const PLANET_ZH: Record<(typeof EPHEMERIS_PLANET_ORDER)[number], string> = {
|
||
Sun: "太阳",
|
||
Moon: "月亮",
|
||
Mars: "火星",
|
||
Mercury: "水星",
|
||
Jupiter: "木星",
|
||
Venus: "金星",
|
||
Saturn: "土星",
|
||
Rahu: "罗睺",
|
||
Ketu: "计都",
|
||
};
|
||
|
||
const VARA_ZH: Record<string, string> = {
|
||
Sunday: "星期日",
|
||
Monday: "星期一",
|
||
Tuesday: "星期二",
|
||
Wednesday: "星期三",
|
||
Thursday: "星期四",
|
||
Friday: "星期五",
|
||
Saturday: "星期六",
|
||
};
|
||
|
||
const QUALITY_ZH: Record<EphemerisQuality, string> = {
|
||
subha: "吉",
|
||
mixed: "中",
|
||
asubha: "凶",
|
||
};
|
||
|
||
const FACTOR_LABEL: Record<EphemerisPanchangaFactor["key"], string> = {
|
||
vara: "星期",
|
||
tithi: "月相日",
|
||
nakshatra: "月宿",
|
||
yoga: "瑜伽",
|
||
karana: "迦那",
|
||
};
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||
? value as Record<string, unknown>
|
||
: null;
|
||
}
|
||
|
||
function asText(value: unknown): string | null {
|
||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function asNumber(value: unknown): number | null {
|
||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||
if (typeof value === "string" && value.trim()) {
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function nestedText(value: unknown, key: string): string | null {
|
||
return asText(asRecord(value)?.[key]);
|
||
}
|
||
|
||
function nestedNumber(value: unknown, key: string): number | null {
|
||
return asNumber(asRecord(value)?.[key]);
|
||
}
|
||
|
||
export function isEphemerisIsoDate(value: string): boolean {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
|
||
const [year, month, day] = value.split("-").map(Number);
|
||
const date = new Date(Date.UTC(year, month - 1, day));
|
||
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||
}
|
||
|
||
export function shiftEphemerisDate(iso: string, days: number): string {
|
||
const [year, month, day] = iso.split("-").map(Number);
|
||
const next = new Date(Date.UTC(year, month - 1, day + days));
|
||
return next.toISOString().slice(0, 10);
|
||
}
|
||
|
||
/**
|
||
* The calendar works in local `Date` objects; the API works in bare ISO days.
|
||
* Both helpers stay on the calendar fields and never round-trip through UTC,
|
||
* so a user east or west of Greenwich cannot land on the neighbouring day.
|
||
*/
|
||
export function parseEphemerisDate(iso: string): Date | undefined {
|
||
if (!isEphemerisIsoDate(iso)) return undefined;
|
||
const [year, month, day] = iso.split("-").map(Number);
|
||
return new Date(year, month - 1, day);
|
||
}
|
||
|
||
export function toEphemerisDate(value: Date): string {
|
||
const year = String(value.getFullYear()).padStart(4, "0");
|
||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||
const day = String(value.getDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
export function formatEphemerisDate(iso: string): string {
|
||
const [year, month, day] = iso.split("-");
|
||
if (!year || !month || !day) return iso;
|
||
return `${Number(year)}年${Number(month)}月${Number(day)}日`;
|
||
}
|
||
|
||
export function ephemerisAskDraft(iso: string): string {
|
||
return `请按 ${formatEphemerisDate(iso)} 这一天的天象来看。`;
|
||
}
|
||
|
||
export function qualityLabel(quality: EphemerisQuality | null): string | null {
|
||
return quality ? QUALITY_ZH[quality] : null;
|
||
}
|
||
|
||
/* ── Readable panchanga (TASK-people-ephemeris-ui-20260926 D5–D7, D10) ──────
|
||
A projection over the unchanged `/api/ephemeris` payload: `factor.value`
|
||
stays the engine name on the wire (星期 was already Chinese there), and the
|
||
Chinese main line is looked up here, from `panchanga-names-zh.ts` only. */
|
||
|
||
export type PanchangaDisplayRow = {
|
||
readonly key: EphemerisPanchangaFactor["key"];
|
||
readonly label: string;
|
||
/** Chinese first; the engine name when the table has no entry. */
|
||
readonly primary: string;
|
||
/** The engine's own (Sanskrit) name under a Chinese main line; null otherwise. */
|
||
readonly secondary: string | null;
|
||
readonly quality: EphemerisQuality | null;
|
||
};
|
||
|
||
const reportedMissingNames = new Set<string>();
|
||
|
||
function reportMissingPanchangaName(key: string, name: string) {
|
||
const id = `${key}:${normalizePanchangaName(name)}`;
|
||
if (reportedMissingNames.has(id)) return;
|
||
reportedMissingNames.add(id);
|
||
console.warn("panchanga_name_zh_missing", { kind: key, name });
|
||
}
|
||
|
||
export function panchangaDisplayRows(factors: readonly EphemerisPanchangaFactor[]): PanchangaDisplayRow[] {
|
||
return factors.map((factor) => {
|
||
const zh = factor.key === "vara" ? null : panchangaNameZh(factor.key, factor.value);
|
||
if (factor.key !== "vara" && !zh) reportMissingPanchangaName(factor.key, factor.value);
|
||
const name = zh ?? factor.value;
|
||
return {
|
||
key: factor.key,
|
||
label: factor.label,
|
||
primary: factor.detail ? `${name} · ${factor.detail}` : name,
|
||
secondary: zh ? factor.value : null,
|
||
quality: factor.quality,
|
||
};
|
||
});
|
||
}
|
||
|
||
export type PanchangaQualityCounts = { readonly good: number; readonly bad: number };
|
||
|
||
/** 吉 = subha, 凶 = asubha. 中 (mixed) counts as neither. */
|
||
export function panchangaQualityCounts(factors: readonly EphemerisPanchangaFactor[]): PanchangaQualityCounts {
|
||
return {
|
||
good: factors.filter((factor) => factor.quality === "subha").length,
|
||
bad: factors.filter((factor) => factor.quality === "asubha").length,
|
||
};
|
||
}
|
||
|
||
const PANCHANGA_KEYS = ["vara", "tithi", "nakshatra", "yoga", "karana"] as const;
|
||
|
||
/** All five limbs present, each with the engine's 吉 / 中 / 凶. */
|
||
export function panchangaComplete(factors: readonly EphemerisPanchangaFactor[]): boolean {
|
||
return PANCHANGA_KEYS.every((key) => {
|
||
const factor = factors.find((item) => item.key === key);
|
||
return Boolean(factor && factor.value.trim() && factor.quality);
|
||
});
|
||
}
|
||
|
||
function moonPhasePhrase(tithi: string): string | null {
|
||
const parts = splitTithiName(tithi);
|
||
if (parts.name === "purnima") return "满月之日";
|
||
if (parts.name === "amavasya") return "新月之日";
|
||
if (parts.paksha === "shukla") return "月亮渐盈";
|
||
if (parts.paksha === "krishna") return "月亮渐亏";
|
||
return null;
|
||
}
|
||
|
||
function overallPhrase(good: number): string {
|
||
if (good >= 4) return "整体吉";
|
||
if (good === 3) return "整体偏吉";
|
||
if (good === 2) return "吉凶参半";
|
||
return "整体偏凶";
|
||
}
|
||
|
||
/**
|
||
* D7: one fixed-rule sentence, no model call.
|
||
* `{月相短语},{整体}{Vishti 追加}。` — null when a limb is missing.
|
||
*/
|
||
export function panchangaDaySummary(factors: readonly EphemerisPanchangaFactor[]): string | null {
|
||
if (!panchangaComplete(factors)) return null;
|
||
const tithi = factors.find((factor) => factor.key === "tithi")!.value;
|
||
const karana = factors.find((factor) => factor.key === "karana")!.value;
|
||
const phase = moonPhasePhrase(tithi);
|
||
if (!phase) return null;
|
||
const overall = overallPhrase(panchangaQualityCounts(factors).good);
|
||
const vishti = normalizePanchangaName(karana) === "vishti" ? ";迦那落在毗湿提,重要的事避开" : "";
|
||
return `${phase},${overall}${vishti}。`;
|
||
}
|
||
|
||
/** Same names as the ayanamsa setting (`AYANAMSA_OPTIONS`), without 「(默认)」. */
|
||
const AYANAMSA_SHORT_NAME: Readonly<Record<AyanamsaName, string>> = {
|
||
raman: "Raman",
|
||
lahiri: "Lahiri",
|
||
kp: "KP",
|
||
true_citra: "True Citra",
|
||
true_pushya: "True Pushya",
|
||
fagan_bradley: "Fagan-Bradley",
|
||
};
|
||
|
||
/**
|
||
* D10: the five limbs are always Lahiri; the chart page uses the subject's own
|
||
* setting (`transits.ayanamsa`). Unknown setting → only the Lahiri half.
|
||
*/
|
||
export function ephemerisAyanamsaNote(chartAyanamsa: string | null | undefined): string {
|
||
const key = typeof chartAyanamsa === "string" ? chartAyanamsa.trim().toLowerCase().replace(/-/g, "_") : "";
|
||
if (!isAyanamsaName(key)) return EPHEMERIS_COPY.panchangaAyanamsaNoteUnknown;
|
||
if (key === "lahiri") return EPHEMERIS_COPY.panchangaAyanamsaNoteSame;
|
||
return EPHEMERIS_COPY.panchangaAyanamsaNoteDifferent.replace("{chart}", AYANAMSA_SHORT_NAME[key]);
|
||
}
|
||
|
||
export function planetLabel(planet: string): string {
|
||
return PLANET_ZH[planet as keyof typeof PLANET_ZH] ?? planet;
|
||
}
|
||
|
||
export function signLabel(sign: string): string {
|
||
return SIGN_ZH[sign as keyof typeof SIGN_ZH] ?? sign;
|
||
}
|
||
|
||
export function signIndex(sign: string | null, explicit?: number | null): number | null {
|
||
if (typeof explicit === "number" && Number.isInteger(explicit) && explicit >= 0 && explicit <= 11) {
|
||
return explicit;
|
||
}
|
||
if (!sign) return null;
|
||
const index = (SIGNS as readonly string[]).indexOf(sign);
|
||
return index >= 0 ? index : null;
|
||
}
|
||
|
||
export function natalHouseFromSigns(transitSign: string | null, natalAscSign: string | null, transitIdx?: number | null, natalIdx?: number | null): number | null {
|
||
const transit = signIndex(transitSign, transitIdx ?? null);
|
||
const natal = signIndex(natalAscSign, natalIdx ?? null);
|
||
if (transit === null || natal === null) return null;
|
||
return ((transit - natal + 12) % 12) + 1;
|
||
}
|
||
|
||
export function formatHouse(house: number | null): string {
|
||
return house === null ? "—" : `第 ${house} 宫`;
|
||
}
|
||
|
||
/** D9 sub-line: 「落你的第 N 宫」, only when the natal ascendant is known. */
|
||
export function transitHouseLine(house: number | null, natalRelativeAvailable: boolean): string | null {
|
||
return natalRelativeAvailable && house !== null ? `落你的第 ${house} 宫` : null;
|
||
}
|
||
|
||
export function formatDegree(degree: number): string {
|
||
return `${degree.toFixed(1)}°`;
|
||
}
|
||
|
||
function parseQuality(value: unknown): EphemerisQuality | null {
|
||
return value === "subha" || value === "asubha" || value === "mixed" ? value : null;
|
||
}
|
||
|
||
function chartModule(chart: unknown): Record<string, unknown> | null {
|
||
const root = asRecord(chart);
|
||
return asRecord(root?.modules)?.chart && asRecord(asRecord(root?.modules)?.chart)
|
||
? asRecord(asRecord(root?.modules)?.chart)
|
||
: root;
|
||
}
|
||
|
||
export function panchangaFactorsFromEngine(report: unknown, date: string): EphemerisPanchangaFactor[] {
|
||
const root = asRecord(report);
|
||
const payload = asRecord(root?.report) ?? root;
|
||
const days = Array.isArray(payload?.days) ? payload.days : [];
|
||
const day = days.map(asRecord).find((entry) => asText(entry?.query_date) === date) ?? asRecord(days[0]);
|
||
const panchanga = asRecord(day?.panchanga);
|
||
if (!panchanga) return [];
|
||
|
||
const varaName = nestedText(panchanga.vara, "vara") ?? nestedText(panchanga.vara, "name");
|
||
const tithiName = nestedText(panchanga.tithi, "full_name") ?? nestedText(panchanga.tithi, "name");
|
||
const nakshatraName = nestedText(panchanga.nakshatra, "nakshatra") ?? nestedText(panchanga.nakshatra, "name");
|
||
const yogaName = nestedText(panchanga.yoga, "yoga") ?? nestedText(panchanga.yoga, "name");
|
||
const karanaName = nestedText(panchanga.karana, "karana") ?? nestedText(panchanga.karana, "name");
|
||
const pada = nestedNumber(panchanga.nakshatra, "pada");
|
||
if (!varaName || !tithiName || !nakshatraName || !yogaName || !karanaName) return [];
|
||
|
||
const factors: EphemerisPanchangaFactor[] = [
|
||
{
|
||
key: "vara",
|
||
label: FACTOR_LABEL.vara,
|
||
value: VARA_ZH[varaName] ?? varaName,
|
||
quality: parseQuality(asRecord(panchanga.vara)?.quality),
|
||
detail: null,
|
||
},
|
||
{
|
||
key: "tithi",
|
||
label: FACTOR_LABEL.tithi,
|
||
value: tithiName,
|
||
quality: parseQuality(asRecord(panchanga.tithi)?.quality),
|
||
detail: null,
|
||
},
|
||
{
|
||
key: "nakshatra",
|
||
label: FACTOR_LABEL.nakshatra,
|
||
value: nakshatraName,
|
||
quality: parseQuality(asRecord(panchanga.nakshatra)?.quality),
|
||
detail: pada !== null && pada >= 1 && pada <= 4 ? `第 ${pada} 足` : null,
|
||
},
|
||
{
|
||
key: "yoga",
|
||
label: FACTOR_LABEL.yoga,
|
||
value: yogaName,
|
||
quality: parseQuality(asRecord(panchanga.yoga)?.quality),
|
||
detail: null,
|
||
},
|
||
{
|
||
key: "karana",
|
||
label: FACTOR_LABEL.karana,
|
||
value: karanaName,
|
||
quality: parseQuality(asRecord(panchanga.karana)?.quality),
|
||
detail: null,
|
||
},
|
||
];
|
||
return factors;
|
||
}
|
||
|
||
export function transitsFromEngine(input: {
|
||
transitChart: unknown;
|
||
natalChart: unknown | null;
|
||
}): { bodies: EphemerisTransitBody[]; natalRelativeAvailable: boolean } {
|
||
const transit = chartModule(input.transitChart);
|
||
const natal = input.natalChart ? chartModule(input.natalChart) : null;
|
||
const planets = asRecord(transit?.planets);
|
||
const natalAsc = asRecord(natal?.ascendant);
|
||
const natalSign = asText(natalAsc?.sign);
|
||
const natalIdx = asNumber(natalAsc?.sign_idx);
|
||
const natalRelativeAvailable = signIndex(natalSign, natalIdx) !== null;
|
||
if (!planets) return { bodies: [], natalRelativeAvailable };
|
||
|
||
const bodies: EphemerisTransitBody[] = [];
|
||
for (const planet of EPHEMERIS_PLANET_ORDER) {
|
||
const row = asRecord(planets[planet]);
|
||
const sign = asText(row?.sign);
|
||
const degree = asNumber(row?.degree_in_sign) ?? asNumber(row?.degree);
|
||
if (!sign || degree === null) continue;
|
||
const degreeInSign = ((degree % 30) + 30) % 30;
|
||
const retrograde = row?.retrograde === true || (asNumber(row?.speed) ?? 0) < 0;
|
||
bodies.push({
|
||
planet,
|
||
planetLabel: planetLabel(planet),
|
||
sign,
|
||
signLabel: signLabel(sign),
|
||
degreeInSign,
|
||
retrograde,
|
||
natalHouse: natalHouseFromSigns(sign, natalSign, asNumber(row?.sign_idx), natalIdx),
|
||
});
|
||
}
|
||
return { bodies, natalRelativeAvailable };
|
||
}
|
||
|
||
function eventKind(value: unknown): "ingress" | "station" | null {
|
||
const raw = asText(value)?.toLowerCase();
|
||
if (raw === "ingress" || raw === "enter" || raw === "sign_ingress") return "ingress";
|
||
if (raw === "station" || raw === "stationary" || raw === "station_direct" || raw === "station_retrograde") {
|
||
return "station";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function stationMotion(row: Record<string, unknown>): "direct" | "retrograde" | null {
|
||
const raw = (asText(row.motion) ?? asText(row.direction) ?? asText(row.station) ?? asText(row.kind) ?? "").toLowerCase();
|
||
if (raw.includes("direct") || raw.includes("顺")) return "direct";
|
||
if (raw.includes("retro") || raw.includes("逆")) return "retrograde";
|
||
if (row.retrograde === true) return "retrograde";
|
||
if (row.retrograde === false) return "direct";
|
||
return null;
|
||
}
|
||
|
||
function eventFact(input: {
|
||
kind: "ingress" | "station";
|
||
planetLabel: string;
|
||
signLabel: string | null;
|
||
degree: number | null;
|
||
motion: "direct" | "retrograde" | null;
|
||
}): string | null {
|
||
if (input.kind === "ingress") {
|
||
if (!input.signLabel) return null;
|
||
return `${input.planetLabel} 进入 ${input.signLabel}`;
|
||
}
|
||
const turn = input.motion === "direct" ? "停滞转顺" : input.motion === "retrograde" ? "停滞转逆" : "停滞";
|
||
if (input.degree === null) return `${input.planetLabel} ${turn}`;
|
||
return `${input.planetLabel} ${turn} · ${formatDegree(input.degree)}`;
|
||
}
|
||
|
||
export function eventsFromEngine(payload: unknown): EphemerisEvents {
|
||
const root = asRecord(payload);
|
||
if (!root) return { status: "unavailable" };
|
||
const list = Array.isArray(root.events)
|
||
? root.events
|
||
: Array.isArray(asRecord(root.report)?.events)
|
||
? asRecord(root.report)?.events
|
||
: Array.isArray(root.items)
|
||
? root.items
|
||
: null;
|
||
if (!Array.isArray(list)) return { status: "unavailable" };
|
||
|
||
const items: EphemerisEventItem[] = [];
|
||
for (const entry of list) {
|
||
const row = asRecord(entry);
|
||
if (!row) continue;
|
||
const date = asText(row.date) ?? asText(row.event_date) ?? asText(row.when);
|
||
const kind = eventKind(row.kind ?? row.type ?? row.event);
|
||
const planet = asText(row.planet) ?? asText(row.body);
|
||
if (!date || !isEphemerisIsoDate(date) || !kind || !planet) continue;
|
||
const sign = asText(row.sign);
|
||
const degree = asNumber(row.degree_in_sign) ?? asNumber(row.degree);
|
||
const fact = eventFact({
|
||
kind,
|
||
planetLabel: planetLabel(planet),
|
||
signLabel: sign ? signLabel(sign) : null,
|
||
degree,
|
||
motion: stationMotion(row),
|
||
});
|
||
if (!fact) continue;
|
||
items.push({
|
||
date,
|
||
kind,
|
||
planet,
|
||
planetLabel: planetLabel(planet),
|
||
fact,
|
||
});
|
||
}
|
||
items.sort((left, right) => left.date.localeCompare(right.date) || left.planet.localeCompare(right.planet));
|
||
return { status: "ok", items };
|
||
}
|
||
|
||
export function addDaysIso(iso: string, days: number): string {
|
||
return shiftEphemerisDate(iso, days);
|
||
}
|