Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
855 lines
31 KiB
TypeScript
855 lines
31 KiB
TypeScript
import {
|
||
toNorthIndianChart,
|
||
type ReportChartBlock,
|
||
} from "./report-chart-block.ts";
|
||
import {
|
||
ANTARDASHA_DERIVED_NOTE,
|
||
CHARA_ANTARDASHA_DATE_NOTE,
|
||
CHART_VIEW_COPY,
|
||
COORDINATE_BOUNDARY,
|
||
DASHA_DUAL_TRACK_NOTE,
|
||
PLANET_ORDER,
|
||
SIGN_ORDER,
|
||
VARGA_CHIP_ORDER,
|
||
VIMSHOTTARI_ORDER,
|
||
VIMSHOTTARI_YEARS,
|
||
WESTERN_UNAVAILABLE_COPY,
|
||
ASPECT_ZH,
|
||
ELEMENT_ZH,
|
||
MODE_ZH,
|
||
WESTERN_BODY_ZH,
|
||
isSignName,
|
||
planetZh,
|
||
signZh,
|
||
} from "./chart-view-labels.ts";
|
||
import { traditionalSymbol } from "./chart-view-symbols.ts";
|
||
import { vargaDignity } from "./chart-view-varga.ts";
|
||
import { transitsFromEngine } from "./ephemeris-view.ts";
|
||
import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts";
|
||
import {
|
||
chartViewOkSchema,
|
||
type ChartViewBhavaOk,
|
||
type ChartViewMoonChart,
|
||
type ChartViewOk,
|
||
type ChartViewTransitOk,
|
||
type ChartViewUnavailableLayer,
|
||
type ChartViewVarga,
|
||
type ChartViewVargaAscendantRow,
|
||
type ChartViewVargaRow,
|
||
type ChartViewWesternOk,
|
||
type NorthIndianChart,
|
||
} from "./chart-view-contract.ts";
|
||
|
||
export type ChartViewProfileInput = {
|
||
name?: string;
|
||
date: string;
|
||
time: string;
|
||
placeLabel?: string;
|
||
latitude: number;
|
||
longitude: number;
|
||
timezoneOffset: number;
|
||
timezoneId?: string;
|
||
ayanamsa?: string;
|
||
birthTimeStatus?: string;
|
||
};
|
||
|
||
export type ChartViewEngineBundle = {
|
||
chart: Record<string, unknown>;
|
||
varga: Record<string, unknown> | null;
|
||
chara: Record<string, unknown> | null;
|
||
western: Record<string, unknown> | null;
|
||
bhava?: Record<string, unknown> | null;
|
||
transit?: Record<string, unknown> | null;
|
||
profile: ChartViewProfileInput;
|
||
asOf: string;
|
||
};
|
||
|
||
function record(value: unknown): Record<string, unknown> | null {
|
||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||
? value as Record<string, unknown>
|
||
: null;
|
||
}
|
||
|
||
function text(value: unknown): string | null {
|
||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function finite(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 isoDay(value: string): string {
|
||
return value.slice(0, 10);
|
||
}
|
||
|
||
function addWeightedSpan(startIso: string, endIso: string, index: number, weights: readonly number[]): { start: string; end: string } {
|
||
const start = Date.parse(`${startIso}T00:00:00Z`);
|
||
const end = Date.parse(`${endIso}T00:00:00Z`);
|
||
const total = weights.reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0);
|
||
// A zero-year Chara mahadasha gives every antardasha weight 0. `??` does not
|
||
// replace 0, so total is 0 and `before / total` is NaN. Never call
|
||
// `Date#toISOString` on that — it throws RangeError and fails the whole chart.
|
||
if (!Number.isFinite(start) || !Number.isFinite(end) || !(total > 0)) {
|
||
return { start: startIso, end: startIso };
|
||
}
|
||
const before = weights.slice(0, index).reduce((sum, weight) => sum + (Number.isFinite(weight) ? weight : 0), 0);
|
||
const weight = Number.isFinite(weights[index]) ? weights[index]! : 0;
|
||
const span = end - start;
|
||
const from = start + span * (before / total);
|
||
const to = index === weights.length - 1 ? end : start + span * ((before + weight) / total);
|
||
if (!Number.isFinite(from) || !Number.isFinite(to)) return { start: startIso, end: startIso };
|
||
return { start: new Date(from).toISOString().slice(0, 10), end: new Date(to).toISOString().slice(0, 10) };
|
||
}
|
||
|
||
function addYears(startIso: string, years: number): string {
|
||
const start = Date.parse(`${startIso}T00:00:00Z`);
|
||
return new Date(start + years * 365.25 * 86_400_000).toISOString().slice(0, 10);
|
||
}
|
||
|
||
function containsDay(start: string, end: string, asOf: string): boolean {
|
||
return start <= asOf && asOf < end;
|
||
}
|
||
|
||
function asPlanetMap(value: unknown): Record<string, Record<string, unknown>> {
|
||
const raw = record(value);
|
||
if (!raw) return {};
|
||
const out: Record<string, Record<string, unknown>> = {};
|
||
for (const [name, planet] of Object.entries(raw)) {
|
||
const row = record(planet);
|
||
if (row) out[name] = row;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function planetLon(planet: Record<string, unknown>): number | null {
|
||
return finite(planet.lon) ?? finite(planet.longitude) ?? finite(planet.degree_raw);
|
||
}
|
||
|
||
function planetDegreeInSign(planet: Record<string, unknown>): number {
|
||
return finite(planet.degree_in_sign) ?? finite(planet.degree) ?? ((planetLon(planet) ?? 0) % 30);
|
||
}
|
||
|
||
function statusLabel(planet: Record<string, unknown>): string {
|
||
const retrograde = Boolean(planet.retrograde);
|
||
const dignity = text(planet.status) ?? "中性";
|
||
return retrograde && !dignity.includes("逆") ? `${dignity} · 逆行` : dignity;
|
||
}
|
||
|
||
function birthTimeStatusLabel(status: string): string {
|
||
if (status === "confirmed") return "已校正";
|
||
if (status === "accepted") return "已采用";
|
||
return "填报时间";
|
||
}
|
||
|
||
function nodeModeLabel(mode: "mean" | "true"): string {
|
||
return mode === "true" ? "真交点" : "平交点";
|
||
}
|
||
|
||
function formatCoord(latitude: number, longitude: number): string {
|
||
const ns = latitude >= 0 ? "N" : "S";
|
||
const ew = longitude >= 0 ? "E" : "W";
|
||
return `${Math.abs(latitude).toFixed(2)}°${ns} ${Math.abs(longitude).toFixed(2)}°${ew}`;
|
||
}
|
||
|
||
function houseFromPlanet(planet: Record<string, unknown>, ascSign: string): number {
|
||
const direct = finite(planet.house);
|
||
if (direct !== null && direct >= 1 && direct <= 12) return Math.trunc(direct);
|
||
const sign = text(planet.sign);
|
||
if (!sign || !isSignName(sign) || !isSignName(ascSign)) return 1;
|
||
return ((SIGN_ORDER.indexOf(sign) - SIGN_ORDER.indexOf(ascSign) + 12) % 12) + 1;
|
||
}
|
||
|
||
function natalBlock(chart: Record<string, unknown>): ReportChartBlock | null {
|
||
const planets = asPlanetMap(chart.planets);
|
||
const ascendant = record(chart.ascendant);
|
||
const ascSign = text(ascendant?.sign);
|
||
if (!ascSign || !isSignName(ascSign)) return null;
|
||
const rows: ReportChartBlock["planets"] = [];
|
||
for (const name of PLANET_ORDER) {
|
||
const planet = planets[name];
|
||
if (!planet) continue;
|
||
const sign = text(planet.sign);
|
||
if (!sign || !isSignName(sign)) continue;
|
||
rows.push({
|
||
name,
|
||
sign,
|
||
degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))),
|
||
retrograde: Boolean(planet.retrograde),
|
||
});
|
||
}
|
||
if (rows.length === 0) return null;
|
||
return {
|
||
version: 1,
|
||
id: "D1",
|
||
title: "D1 — 本命盘",
|
||
layout: "north",
|
||
ascendant: {
|
||
sign: ascSign,
|
||
degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree_in_sign) ?? finite(ascendant?.degree) ?? 0)),
|
||
},
|
||
planets: rows,
|
||
};
|
||
}
|
||
|
||
function vargaKey(division: number, result: Record<string, unknown>): Record<string, unknown> | null {
|
||
const exact = Object.entries(result).find(([key]) => key === `D${division}` || key.startsWith(`D${division}_`));
|
||
return exact ? record(exact[1]) : null;
|
||
}
|
||
|
||
type NatalSigns = { planets: ReadonlyMap<string, string>; ascendant: string | null };
|
||
|
||
function houseFromSigns(sign: string, ascSign: string): number {
|
||
return ((SIGN_ORDER.indexOf(sign as (typeof SIGN_ORDER)[number]) - SIGN_ORDER.indexOf(ascSign as (typeof SIGN_ORDER)[number]) + 12) % 12) + 1;
|
||
}
|
||
|
||
/**
|
||
* The divisional chart's own rows: the engine's divisional sign, in-varga
|
||
* degree and house (counted from the divisional ascendant), plus the D1 sign
|
||
* each body came from. Nothing is recomputed here except the sign-dignity
|
||
* lookup, whose tables are the engine's (`chart-view-varga.ts`).
|
||
*/
|
||
function vargaRows(
|
||
ascSign: string,
|
||
varga: Record<string, unknown>,
|
||
natalRetro: ReadonlyMap<string, boolean>,
|
||
natal: NatalSigns,
|
||
): { ascendant: ChartViewVargaAscendantRow | undefined; rows: ChartViewVargaRow[] } {
|
||
const planets = asPlanetMap(varga.planets);
|
||
const rows: ChartViewVargaRow[] = [];
|
||
for (const name of PLANET_ORDER) {
|
||
const planet = planets[name];
|
||
const sign = text(planet?.sign);
|
||
const natalSign = natal.planets.get(name);
|
||
if (!planet || !sign || !isSignName(sign) || !natalSign) continue;
|
||
const engineHouse = finite(planet.house);
|
||
rows.push({
|
||
name,
|
||
label: planetZh(name),
|
||
sign,
|
||
signLabel: signZh(sign),
|
||
degreeInSign: Math.min(29.9999, Math.max(0, planetDegreeInSign(planet))),
|
||
house: engineHouse !== null && engineHouse >= 1 && engineHouse <= 12 ? Math.trunc(engineHouse) : houseFromSigns(sign, ascSign),
|
||
natalSign,
|
||
natalSignLabel: signZh(natalSign),
|
||
dignity: vargaDignity(name, sign),
|
||
vargottama: sign === natalSign,
|
||
retrograde: natalRetro.get(name) ?? false,
|
||
});
|
||
}
|
||
const ascendant = record(varga.ascendant);
|
||
const ascendantRow = natal.ascendant ? {
|
||
sign: ascSign,
|
||
signLabel: signZh(ascSign),
|
||
degreeInSign: Math.min(29.9999, Math.max(0, finite(ascendant?.degree) ?? 0)),
|
||
natalSign: natal.ascendant,
|
||
natalSignLabel: signZh(natal.ascendant),
|
||
vargottama: ascSign === natal.ascendant,
|
||
} : undefined;
|
||
return { ascendant: ascendantRow, rows };
|
||
}
|
||
|
||
function vargaToChart(
|
||
id: string,
|
||
title: string,
|
||
varga: Record<string, unknown>,
|
||
natalRetro: ReadonlyMap<string, boolean>,
|
||
): { chart: NorthIndianChart; meaning: string; division: number } | null {
|
||
const ascendant = record(varga.ascendant);
|
||
const ascSign = text(ascendant?.sign);
|
||
if (!ascSign || !isSignName(ascSign)) return null;
|
||
const planets = asPlanetMap(varga.planets);
|
||
const rows: ReportChartBlock["planets"] = [];
|
||
for (const name of PLANET_ORDER) {
|
||
const planet = planets[name];
|
||
if (!planet) continue;
|
||
const sign = text(planet.sign);
|
||
if (!sign || !isSignName(sign)) continue;
|
||
rows.push({
|
||
name,
|
||
sign,
|
||
degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))),
|
||
retrograde: natalRetro.get(name) ?? false,
|
||
});
|
||
}
|
||
const division = finite(varga.division) ?? Number(id.slice(1));
|
||
const meaning = text(varga.meaning) ?? title;
|
||
const block: ReportChartBlock = {
|
||
version: 1,
|
||
id: id as ReportChartBlock["id"],
|
||
title,
|
||
layout: "north",
|
||
ascendant: {
|
||
sign: ascSign,
|
||
degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree) ?? 0)),
|
||
},
|
||
planets: rows,
|
||
};
|
||
return { chart: toNorthIndianChart(block), meaning, division };
|
||
}
|
||
|
||
function buildVargas(
|
||
chart: Record<string, unknown>,
|
||
vargaPacket: Record<string, unknown> | null,
|
||
): ChartViewVarga[] {
|
||
const natalRetro = new Map<string, boolean>();
|
||
const natalPlanetSigns = new Map<string, string>();
|
||
for (const [name, planet] of Object.entries(asPlanetMap(chart.planets))) {
|
||
natalRetro.set(name, Boolean(planet.retrograde));
|
||
const sign = text(planet.sign);
|
||
if (sign && isSignName(sign)) natalPlanetSigns.set(name, sign);
|
||
}
|
||
const natalAsc = text(record(chart.ascendant)?.sign);
|
||
const natal: NatalSigns = { planets: natalPlanetSigns, ascendant: natalAsc && isSignName(natalAsc) ? natalAsc : null };
|
||
const result = record(vargaPacket?.result) ?? {};
|
||
const vargas: ChartViewVarga[] = [];
|
||
for (const division of VARGA_CHIP_ORDER) {
|
||
const raw = vargaKey(division, result);
|
||
const id = `D${division}`;
|
||
if (raw) {
|
||
const mapped = vargaToChart(id, `${id} ${text(raw.name) ?? ""}`.trim(), raw, natalRetro);
|
||
if (mapped) {
|
||
const ascSign = text(record(raw.ascendant)?.sign)!;
|
||
vargas.push({
|
||
id,
|
||
title: id,
|
||
meaning: mapped.meaning,
|
||
division: mapped.division,
|
||
chart: mapped.chart,
|
||
...(division === 1 ? {} : vargaRows(ascSign, raw, natalRetro, natal)),
|
||
});
|
||
continue;
|
||
}
|
||
}
|
||
if (division === 1) {
|
||
const block = natalBlock(chart);
|
||
if (block) {
|
||
vargas.push({
|
||
id: "D1",
|
||
title: "D1",
|
||
meaning: "本命盘",
|
||
division: 1,
|
||
chart: toNorthIndianChart(block),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return vargas;
|
||
}
|
||
|
||
function d9SignFor(name: string, vargaPacket: Record<string, unknown> | null): string | null {
|
||
const result = record(vargaPacket?.result) ?? {};
|
||
const d9 = vargaKey(9, result);
|
||
const planet = asPlanetMap(d9?.planets)[name];
|
||
return text(planet?.sign);
|
||
}
|
||
|
||
function planetRows(chart: Record<string, unknown>, vargaPacket: Record<string, unknown> | null) {
|
||
const planets = asPlanetMap(chart.planets);
|
||
const ascSign = text(record(chart.ascendant)?.sign) ?? "Aries";
|
||
return PLANET_ORDER.flatMap((name) => {
|
||
const planet = planets[name];
|
||
if (!planet) return [];
|
||
const sign = text(planet.sign) ?? "Aries";
|
||
const d9 = d9SignFor(name, vargaPacket);
|
||
return [{
|
||
name,
|
||
label: planetZh(name),
|
||
sign,
|
||
signLabel: signZh(sign),
|
||
degreeInSign: planetDegreeInSign(planet),
|
||
house: houseFromPlanet(planet, ascSign),
|
||
retrograde: Boolean(planet.retrograde),
|
||
status: statusLabel(planet),
|
||
nakshatra: text(planet.nakshatra) ?? "—",
|
||
pada: Math.min(4, Math.max(1, Math.trunc(finite(planet.nakshatra_pada) ?? 1))),
|
||
nakshatraLord: planetZh(text(planet.nakshatra_lord) ?? "—"),
|
||
d9Sign: d9,
|
||
d9SignLabel: d9 ? signZh(d9) : null,
|
||
symbol: traditionalSymbol(name, sign),
|
||
}];
|
||
});
|
||
}
|
||
|
||
/**
|
||
* The ascendant as a planet-table row. Sign and degree come straight from the
|
||
* engine's `ascendant`; nakshatra, pada and their lord stay null because the
|
||
* engine does not return them for a chart point and they must not be guessed.
|
||
*/
|
||
function ascendantRow(chart: Record<string, unknown>) {
|
||
const ascendant = record(chart.ascendant);
|
||
const sign = text(ascendant?.sign);
|
||
if (!ascendant || !sign) return null;
|
||
const nakshatra = text(ascendant.nakshatra);
|
||
const pada = finite(ascendant.nakshatra_pada);
|
||
const lord = text(ascendant.nakshatra_lord);
|
||
return {
|
||
label: "上升",
|
||
abbr: "As",
|
||
sign,
|
||
signLabel: signZh(sign),
|
||
degreeInSign: planetDegreeInSign(ascendant),
|
||
house: 1 as const,
|
||
retrograde: false as const,
|
||
nakshatra,
|
||
pada: pada === null ? null : Math.min(4, Math.max(1, Math.trunc(pada))),
|
||
nakshatraLord: lord ? planetZh(lord) : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The Moon chart: the natal block with the Moon's sign as house 1. Every sign,
|
||
* degree and direction is the D1's; only the house count changes.
|
||
*/
|
||
function moonChart(chart: Record<string, unknown>, vargaPacket: Record<string, unknown> | null): ChartViewMoonChart | null {
|
||
const block = natalBlock(chart);
|
||
const moon = asPlanetMap(chart.planets).Moon;
|
||
const moonSign = text(moon?.sign);
|
||
const natalAsc = text(record(chart.ascendant)?.sign);
|
||
if (!block || !moon || !moonSign || !isSignName(moonSign) || !natalAsc || !isSignName(natalAsc)) return null;
|
||
const moonBlock: ReportChartBlock = {
|
||
...block,
|
||
id: "MOON",
|
||
title: "月亮盘",
|
||
ascendant: { sign: moonSign, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(moon))) },
|
||
};
|
||
return {
|
||
moonSign,
|
||
moonSignLabel: signZh(moonSign),
|
||
ascendantHouse: houseFromSigns(natalAsc, moonSign),
|
||
chart: toNorthIndianChart(moonBlock),
|
||
planets: planetRows(chart, vargaPacket).map((row) => ({ ...row, house: houseFromSigns(row.sign, moonSign) })),
|
||
};
|
||
}
|
||
|
||
function unavailableTransit(reason: string): ChartViewUnavailableLayer {
|
||
return { status: "unavailable", reason, copy: CHART_VIEW_COPY.unavailable };
|
||
}
|
||
|
||
/**
|
||
* Today's positions placed in the natal houses. The rows are the ephemeris
|
||
* page's own projection (`transitsFromEngine`), so the two pages agree.
|
||
*/
|
||
function mapTransit(
|
||
raw: Record<string, unknown> | null | undefined,
|
||
chart: Record<string, unknown>,
|
||
profile: ChartViewProfileInput,
|
||
asOf: string,
|
||
): ChartViewTransitOk | ChartViewUnavailableLayer {
|
||
if (!raw) return unavailableTransit("not_requested");
|
||
const natalAsc = text(record(chart.ascendant)?.sign);
|
||
if (!natalAsc || !isSignName(natalAsc)) return unavailableTransit("natal_ascendant_missing");
|
||
const { bodies } = transitsFromEngine({ transitChart: raw, natalChart: chart });
|
||
const natalPlanets = asPlanetMap(chart.planets);
|
||
const rows = bodies.flatMap((body) => {
|
||
if (body.natalHouse === null || !isSignName(body.sign)) return [];
|
||
const natal = natalPlanets[body.planet];
|
||
return [{
|
||
name: body.planet,
|
||
label: planetZh(body.planet),
|
||
sign: body.sign,
|
||
signLabel: signZh(body.sign),
|
||
degreeInSign: body.degreeInSign,
|
||
retrograde: body.retrograde,
|
||
natalHouse: body.natalHouse,
|
||
natalPlanetHouse: natal ? houseFromPlanet(natal, natalAsc) : null,
|
||
}];
|
||
});
|
||
if (rows.length === 0) return unavailableTransit("payload_incomplete");
|
||
const block: ReportChartBlock = {
|
||
version: 1,
|
||
id: "D1",
|
||
title: "行运",
|
||
layout: "north",
|
||
ascendant: { sign: natalAsc, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(record(chart.ascendant) ?? {}))) },
|
||
planets: rows.map((row) => ({
|
||
name: row.name as ReportChartBlock["planets"][number]["name"],
|
||
sign: row.sign,
|
||
degree: Math.min(29.999, Math.max(0, row.degreeInSign)),
|
||
retrograde: row.retrograde,
|
||
})),
|
||
};
|
||
return {
|
||
status: "ok",
|
||
date: isoDay(asOf),
|
||
time: TRANSIT_LOCAL_TIME,
|
||
timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`,
|
||
chart: toNorthIndianChart(block),
|
||
rows,
|
||
};
|
||
}
|
||
|
||
function unavailableBhava(reason: string): ChartViewUnavailableLayer {
|
||
return { status: "unavailable", reason, copy: CHART_VIEW_COPY.unavailable };
|
||
}
|
||
|
||
function signAt(lon: number): { sign: (typeof SIGN_ORDER)[number]; degree: number } {
|
||
const normalized = ((lon % 360) + 360) % 360;
|
||
return { sign: SIGN_ORDER[Math.floor(normalized / 30) % 12]!, degree: normalized % 30 };
|
||
}
|
||
|
||
/**
|
||
* Sripati Bhava Chalit from `/api/bhava_chalit` (mode `compare`). Refuses to
|
||
* draw anything the engine did not compute as Sripati from a real MC: a
|
||
* fallback, a missing boundary table, or the old `asc + 270` placeholder MC
|
||
* (BUG-1077) all come back unavailable instead of an equal-house chart.
|
||
*/
|
||
function mapBhava(raw: Record<string, unknown> | null | undefined, chart: Record<string, unknown>): ChartViewBhavaOk | ChartViewUnavailableLayer {
|
||
if (!raw) return unavailableBhava("not_requested");
|
||
const result = record(raw.result);
|
||
if (!result) return unavailableBhava("payload_incomplete");
|
||
if (text(raw.selected_house_system) !== "sripati" || text(raw.fallback_reason)) return unavailableBhava("house_system_fallback");
|
||
const asc = finite(result.ascendant_lon);
|
||
const mc = finite(result.mc_lon);
|
||
if (asc === null || mc === null) return unavailableBhava("mc_missing");
|
||
const placeholder = (asc + 270) % 360;
|
||
if (Math.abs(mc - placeholder) < 1e-6) return unavailableBhava("mc_placeholder");
|
||
const natalAsc = text(record(chart.ascendant)?.sign);
|
||
if (!natalAsc || !isSignName(natalAsc)) return unavailableBhava("natal_ascendant_missing");
|
||
const rashi = asPlanetMap(result.rashi_chart);
|
||
const bhavaHouses = record(result.bhava_chart) ?? {};
|
||
const rows: ChartViewBhavaOk["rows"] = [];
|
||
for (const name of PLANET_ORDER) {
|
||
const planet = rashi[name];
|
||
const sign = text(planet?.sign);
|
||
const rashiHouse = finite(planet?.house);
|
||
const bhavaHouse = finite(bhavaHouses[name]);
|
||
if (!planet || !sign || !isSignName(sign) || rashiHouse === null || bhavaHouse === null) continue;
|
||
rows.push({
|
||
name,
|
||
label: planetZh(name),
|
||
sign,
|
||
signLabel: signZh(sign),
|
||
degreeInSign: Math.min(29.9999, Math.max(0, finite(planet.degree_in_sign) ?? 0)),
|
||
rashiHouse: Math.trunc(rashiHouse),
|
||
bhavaHouse: Math.trunc(bhavaHouse),
|
||
shifted: Math.trunc(bhavaHouse) !== Math.trunc(rashiHouse),
|
||
});
|
||
}
|
||
const houses = Array.isArray(record(result.boundaries)?.houses) ? record(result.boundaries)!.houses as unknown[] : [];
|
||
const cusps: ChartViewBhavaOk["cusps"] = houses.flatMap((entry) => {
|
||
const house = record(entry);
|
||
const number = finite(house?.house);
|
||
const cusp = finite(house?.cusp_lon);
|
||
const sandhi = finite(house?.sandhi_start_lon);
|
||
if (!house || number === null || cusp === null || sandhi === null) return [];
|
||
const at = signAt(cusp);
|
||
const start = signAt(sandhi);
|
||
return [{
|
||
house: Math.trunc(number),
|
||
sign: at.sign,
|
||
signLabel: signZh(at.sign),
|
||
degreeInSign: at.degree,
|
||
sandhiSign: start.sign,
|
||
sandhiSignLabel: signZh(start.sign),
|
||
sandhiDegreeInSign: start.degree,
|
||
}];
|
||
});
|
||
if (rows.length === 0 || cusps.length !== 12) return unavailableBhava("payload_incomplete");
|
||
// Draw each body in its Bhava house: the North Indian block places a body by
|
||
// sign, so hand it the sign that occupies that house from the natal lagna.
|
||
const ascIndex = SIGN_ORDER.indexOf(natalAsc);
|
||
const block: ReportChartBlock = {
|
||
version: 1,
|
||
id: "D1",
|
||
title: "Bhava",
|
||
layout: "north",
|
||
ascendant: { sign: natalAsc, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(record(chart.ascendant) ?? {}))) },
|
||
planets: rows.map((row) => ({
|
||
name: row.name as ReportChartBlock["planets"][number]["name"],
|
||
sign: SIGN_ORDER[(ascIndex + row.bhavaHouse - 1) % 12]!,
|
||
degree: Math.min(29.999, row.degreeInSign),
|
||
retrograde: Boolean(asPlanetMap(chart.planets)[row.name]?.retrograde),
|
||
})),
|
||
};
|
||
return { status: "ok", houseSystem: "Sripati", chart: toNorthIndianChart(block), rows, cusps };
|
||
}
|
||
|
||
function vimshottariTrack(chart: Record<string, unknown>, asOf: string) {
|
||
const dasha = record(chart.dasha) ?? {};
|
||
const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : [];
|
||
const periods = rawPeriods.flatMap((entry) => {
|
||
const row = record(entry);
|
||
if (!row) return [];
|
||
const lord = text(row.lord);
|
||
const start = text(row.start);
|
||
const end = text(row.end);
|
||
if (!lord || !start || !end) return [];
|
||
const startIso = isoDay(start);
|
||
const endIso = isoDay(end);
|
||
const startIndex = VIMSHOTTARI_ORDER.indexOf(lord as (typeof VIMSHOTTARI_ORDER)[number]);
|
||
const order = startIndex >= 0
|
||
? VIMSHOTTARI_ORDER.map((_, index) => VIMSHOTTARI_ORDER[(startIndex + index) % VIMSHOTTARI_ORDER.length]!)
|
||
: [...VIMSHOTTARI_ORDER];
|
||
const weights = order.map((name) => VIMSHOTTARI_YEARS[name]);
|
||
const antardashas = order.map((subLord, index) => {
|
||
const span = addWeightedSpan(startIso, endIso, index, weights);
|
||
return {
|
||
lord: subLord,
|
||
label: planetZh(subLord),
|
||
start: span.start,
|
||
end: span.end,
|
||
current: containsDay(span.start, span.end, asOf),
|
||
derived: true,
|
||
};
|
||
});
|
||
return [{
|
||
lord,
|
||
label: planetZh(lord),
|
||
sign: null,
|
||
start: startIso,
|
||
end: endIso,
|
||
current: containsDay(startIso, endIso, asOf),
|
||
derived: false,
|
||
antardashas,
|
||
}];
|
||
});
|
||
const current = periods.find((item) => item.current);
|
||
const currentAntar = current?.antardashas.find((item) => item.current);
|
||
const currentLabel = current
|
||
? `${planetZh(current.lord)}大运 / ${currentAntar ? planetZh(currentAntar.lord) : "—"}分运`
|
||
: "未落入已返回的时段";
|
||
return {
|
||
id: "vimshottari" as const,
|
||
title: "Vimshottari",
|
||
method: "Vimshottari",
|
||
currentLabel,
|
||
antardashaNote: ANTARDASHA_DERIVED_NOTE,
|
||
periods,
|
||
};
|
||
}
|
||
|
||
function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: string, asOf: string) {
|
||
const result = record(charaPacket?.result) ?? charaPacket;
|
||
const chara = record(result?.chara_dasha) ?? result;
|
||
const sequence = Array.isArray(chara?.dasha_sequence) ? chara.dasha_sequence : [];
|
||
let cursor = birthDate;
|
||
const periods = sequence.flatMap((entry) => {
|
||
const row = record(entry);
|
||
if (!row) return [];
|
||
const sign = text(row.sign);
|
||
const lord = text(row.lord) ?? sign ?? "—";
|
||
const rawDuration = finite(row.duration_years);
|
||
const duration = rawDuration === null ? 1 : rawDuration;
|
||
const zeroYear = duration === 0;
|
||
const start = cursor;
|
||
const end = addYears(start, duration);
|
||
cursor = end;
|
||
const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : [];
|
||
const antardashas = zeroYear
|
||
? []
|
||
: rawAntar.length > 0
|
||
? rawAntar.flatMap((antar, index) => {
|
||
const item = record(antar);
|
||
if (!item) return [];
|
||
const weights = rawAntar.map((candidate) => finite(record(candidate)?.duration_years) ?? 1);
|
||
const span = addWeightedSpan(start, end, index, weights);
|
||
const antarLord = text(item.lord) ?? text(item.sign) ?? "—";
|
||
return [{
|
||
lord: antarLord,
|
||
label: `${signZh(text(item.sign) ?? antarLord)} / ${planetZh(antarLord)}`,
|
||
start: span.start,
|
||
end: span.end,
|
||
current: containsDay(span.start, span.end, asOf),
|
||
derived: true,
|
||
}];
|
||
})
|
||
: SIGN_ORDER.map((antarSign, index) => {
|
||
const span = addWeightedSpan(start, end, index, SIGN_ORDER.map(() => 1));
|
||
return {
|
||
lord: antarSign,
|
||
label: signZh(antarSign),
|
||
start: span.start,
|
||
end: span.end,
|
||
current: containsDay(span.start, span.end, asOf),
|
||
derived: true,
|
||
};
|
||
});
|
||
return [{
|
||
lord,
|
||
label: `${signZh(sign ?? lord)} / ${planetZh(lord)}`,
|
||
sign: sign,
|
||
start,
|
||
end,
|
||
current: containsDay(start, end, asOf),
|
||
derived: false,
|
||
...(zeroYear ? { zeroYear: true as const } : {}),
|
||
antardashas,
|
||
}];
|
||
});
|
||
const current = periods.find((item) => item.current);
|
||
const currentAntar = current?.antardashas.find((item) => item.current);
|
||
const currentLabel = current
|
||
? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}`
|
||
: "未落入已返回的时段";
|
||
return {
|
||
id: "chara" as const,
|
||
title: "Chara Dasha(kn_rao 变体)",
|
||
method: text(chara?.method) === "Chara Dasha (kn_rao)"
|
||
? "Chara Dasha(kn_rao 变体)"
|
||
: (text(chara?.method) ?? "Chara Dasha(kn_rao 变体)"),
|
||
currentLabel,
|
||
antardashaNote: CHARA_ANTARDASHA_DATE_NOTE,
|
||
periods,
|
||
};
|
||
}
|
||
|
||
function unavailableWestern(reason: string): ChartViewUnavailableLayer {
|
||
return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY };
|
||
}
|
||
|
||
function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string): ChartViewWesternOk | ChartViewUnavailableLayer {
|
||
if (!raw) return unavailableWestern("endpoint_missing");
|
||
const natal = record(raw.natal) ?? raw;
|
||
const ascendant = record(natal.ascendant);
|
||
const planetsRaw = record(natal.planets);
|
||
const housesRaw = Array.isArray(natal.houses) ? natal.houses : [];
|
||
if (!ascendant || !planetsRaw || housesRaw.length !== 12) return unavailableWestern("payload_incomplete");
|
||
const ascLon = finite(ascendant.longitude);
|
||
if (ascLon === null) return unavailableWestern("payload_incomplete");
|
||
const planets = Object.entries(planetsRaw).flatMap(([id, value]) => {
|
||
const planet = record(value);
|
||
if (!planet) return [];
|
||
const longitude = finite(planet.longitude);
|
||
const sign = text(planet.sign);
|
||
if (longitude === null || !sign) return [];
|
||
return [{
|
||
id,
|
||
label: WESTERN_BODY_ZH[id] ?? id,
|
||
longitude,
|
||
sign,
|
||
signLabel: signZh(sign),
|
||
degreeInSign: finite(planet.degree_in_sign) ?? (longitude % 30),
|
||
house: finite(planet.house) !== null ? Math.trunc(finite(planet.house)!) : null,
|
||
retrograde: Boolean(planet.retrograde),
|
||
}];
|
||
});
|
||
const houses = housesRaw.flatMap((entry, index) => {
|
||
const row = record(entry);
|
||
const cusp = record(row?.cusp) ?? row;
|
||
if (!cusp) return [];
|
||
const longitude = finite(cusp.longitude);
|
||
const sign = text(cusp.sign);
|
||
if (longitude === null || !sign) return [];
|
||
return [{
|
||
house: Math.trunc(finite(row?.house) ?? index + 1),
|
||
longitude,
|
||
sign,
|
||
signLabel: signZh(sign),
|
||
degreeInSign: finite(cusp.degree_in_sign) ?? (longitude % 30),
|
||
}];
|
||
});
|
||
if (planets.length === 0 || houses.length !== 12) return unavailableWestern("payload_incomplete");
|
||
const aspectsRaw = Array.isArray(natal.aspects) ? natal.aspects : [];
|
||
const aspects = aspectsRaw.flatMap((entry) => {
|
||
const row = record(entry);
|
||
if (!row) return [];
|
||
const left = text(row.left);
|
||
const right = text(row.right);
|
||
const aspect = text(row.aspect);
|
||
if (!left || !right || !aspect) return [];
|
||
return [{
|
||
left: WESTERN_BODY_ZH[left] ?? left,
|
||
right: WESTERN_BODY_ZH[right] ?? right,
|
||
aspect,
|
||
aspectLabel: ASPECT_ZH[aspect] ?? aspect,
|
||
orb: finite(row.orb) ?? 0,
|
||
}];
|
||
});
|
||
const distribution = record(natal.distribution);
|
||
const elementsRaw = record(distribution?.elements) ?? {};
|
||
const modesRaw = record(distribution?.modes) ?? {};
|
||
return {
|
||
status: "ok",
|
||
zodiac: "tropical",
|
||
houseSystem: text(raw.house_system) ?? "P",
|
||
boundary: COORDINATE_BOUNDARY.western,
|
||
ayanamsaGapNote: `回归黄道与本页印度盘所用${ayanamsaDisplay}岁差不是同一套;差值随日期变,不能把两边度数对过去。`,
|
||
ascendantLongitude: ascLon,
|
||
planets,
|
||
houses,
|
||
aspects,
|
||
elements: ["fire", "earth", "air", "water"].map((id) => ({
|
||
id,
|
||
label: ELEMENT_ZH[id] ?? id,
|
||
count: Math.max(0, Math.trunc(finite(elementsRaw[id]) ?? 0)),
|
||
})),
|
||
modes: ["cardinal", "fixed", "mutable"].map((id) => ({
|
||
id,
|
||
label: MODE_ZH[id] ?? id,
|
||
count: Math.max(0, Math.trunc(finite(modesRaw[id]) ?? 0)),
|
||
})),
|
||
};
|
||
}
|
||
|
||
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||
const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle;
|
||
const planets = asPlanetMap(chart.planets);
|
||
const moon = planets.Moon ?? {};
|
||
const ascendant = record(chart.ascendant);
|
||
const birth = record(chart.birth) ?? {};
|
||
const ayanamsa = text(birth.ayanamsa_name) ?? profile.ayanamsa ?? "raman";
|
||
const ayanamsaDisplay = text(birth.ayanamsa_display) ?? ayanamsa;
|
||
const nodeMode = text(birth.node_mode) === "true" ? "true" : "mean";
|
||
const vargas = buildVargas(chart, varga);
|
||
if (vargas.length === 0) throw new Error("vedic_chart_unavailable");
|
||
const vimshottari = vimshottariTrack(chart, asOf);
|
||
const charaMapped = charaTrack(chara, profile.date, asOf);
|
||
const placeLabel = profile.placeLabel?.trim()
|
||
|| formatCoord(profile.latitude, profile.longitude);
|
||
const view = {
|
||
status: "ok" as const,
|
||
billed: false as const,
|
||
profile: {
|
||
name: profile.name?.trim() || "未命名",
|
||
date: profile.date,
|
||
time: profile.time.slice(0, 5),
|
||
placeLabel,
|
||
latitude: profile.latitude,
|
||
longitude: profile.longitude,
|
||
timezoneOffset: profile.timezoneOffset,
|
||
timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`,
|
||
ayanamsa,
|
||
ayanamsaDisplay,
|
||
nodeMode,
|
||
nodeModeLabel: nodeModeLabel(nodeMode),
|
||
birthTimeStatus: profile.birthTimeStatus ?? "reported",
|
||
birthTimeStatusLabel: birthTimeStatusLabel(profile.birthTimeStatus ?? "reported"),
|
||
engineName: "Swiss Ephemeris(本站)",
|
||
moonNakshatra: text(moon.nakshatra) ?? "—",
|
||
moonPada: Math.min(4, Math.max(1, Math.trunc(finite(moon.nakshatra_pada) ?? 1))),
|
||
ascendantSign: text(ascendant?.sign) ?? "—",
|
||
ascendantSignLabel: signZh(text(ascendant?.sign) ?? "—"),
|
||
currentDashaLabel: vimshottari.currentLabel,
|
||
},
|
||
vedic: {
|
||
coordinateSystem: "sidereal" as const,
|
||
boundary: COORDINATE_BOUNDARY.vedic,
|
||
vargas,
|
||
ascendant: ascendantRow(chart),
|
||
planets: planetRows(chart, varga),
|
||
moon: moonChart(chart, varga),
|
||
},
|
||
dasha: {
|
||
asOf: isoDay(asOf),
|
||
vimshottari,
|
||
chara: charaMapped,
|
||
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
|
||
},
|
||
western: mapWestern(western, ayanamsaDisplay),
|
||
bhava: mapBhava(bhava, chart),
|
||
transit: mapTransit(transit, chart, profile, asOf),
|
||
};
|
||
return chartViewOkSchema.parse(view);
|
||
}
|
||
|
||
export function engineChartHasPlanets(chart: Record<string, unknown>): boolean {
|
||
return PLANET_ORDER.some((name) => Boolean(asPlanetMap(chart.planets)[name]));
|
||
}
|
||
|
||
|