Files
Jyotisha/frontend/src/lib/ephemeris-view.ts
T
Jesse_ChenandClaude Opus 5.5 873fac8a5b feat(settings,reports): six ayanamsa options; report-scope solid button colours (T6/T7, BUG-1078)
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
2026-09-28 15:54:53 +08:00

499 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}