Files
Jyotisha/frontend/src/lib/chart-view-mapper.ts
T

855 lines
31 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 {
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]));
}