Files
Jyotisha/frontend/src/lib/chart-view-mapper.ts
T
Jesse_ChenandClaude Opus 5.5 ae18952e51
Independent Staging Quality Gate / validate (push) Successful in 15m27s
Independent Staging Quality Gate / publish (push) Successful in 3m42s
fix(report,chart): blank columns — drop Kranti, split Ashtakavarga house SAV, ascendant nakshatra, engine varga dignity, no duplicate charts (BUG-1199..1203)
- Report p33 table: no Kranti column (no producer; materials.kranti stays blocked).
- Report p53 table: the "SAV" column was the with-Lagna total (386). Split in the
  reader into SAV / Lagna BAV / SAV + Lagna with a totals row; ashtakavarga.py is
  untouched (frozen rectification identity, ERR-115).
- /api/chart ascendant carries nakshatra / pada / lord (_nakshatra_fields shared with
  the nine bodies); chart cache schema 4 -> 5.
- /api/varga_full bodies carry dignity_level from varga_dignity_level, the function
  the Vimsopaka path now shares (A/B identical); the chart page shows the D1 status
  wording and no longer derives a three-level dignity itself.
- Report page draws each chart once: the trailing block skips charts the body already
  fenced (same rule as the export); the birth-time caveat moves under the body's
  chart heading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-10-03 09:11:15 +08:00

861 lines
32 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 { vargaDignityLevel } 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, house (counted from the divisional ascendant) and dignity level,
* plus the D1 sign each body came from. Nothing is recomputed here.
*/
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: vargaDignityLevel(planet.dignity_level),
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)!;
const table = division === 1 ? null : vargaRows(ascSign, raw, natalRetro, natal);
vargas.push({
id,
title: id,
meaning: mapped.meaning,
division: mapped.division,
chart: mapped.chart,
...(table ? {
...table,
// Availability follows validated source rows, never positive hits.
// The engine gives every body a level; a row without one is an
// older cached payload, and then the column is hidden (BUG-1202).
supportsDignity: table.rows.length > 0 && table.rows.every((row) => row.dignity !== null),
supportsVargottama: division === 9 && (table.rows.length > 0 || table.ascendant !== undefined),
} : {}),
});
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, straight from the engine's `ascendant`
* (nakshatra, pada and lord since BUG-1201). An older cached chart without
* them leaves the three cells empty; they are never derived here.
*/
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"),
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]));
}