fix(chart): Sripati Bhava from the real sidereal MC; Bhava chart and tables (T5, BUG-1077)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:38:24 +08:00
co-authored by Claude Opus 5.5
parent 3f6bcb081a
commit c35e0195e1
16 changed files with 743 additions and 9 deletions
+3
View File
@@ -5091,6 +5091,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-page-table-note { margin: 0 0 var(--space-2); }
/* A note + table pair is one grid item, so the table keeps its own scroll box. */
.chart-page-varga-table-block { min-width: 0; }
.chart-page-varga-table-block > .chart-page-planet-table-wrap + .chart-page-planet-table-wrap { margin-top: var(--space-4); }
/* A body the Bhava boundaries move to another house. */
.chart-page-planet-table tr.is-shifted { background: var(--color-selected); }
.chart-page-waiting-plot {
width: min(100%, 400px);
min-width: 0;
@@ -37,7 +37,8 @@ export function ChartPageRoute() {
* the natal packet that is already on the page, so they request nothing.
*/
function chartTypeLayer(id: ChartTypeId): ChartViewLayer | null {
if (id === "D1" || id === "MOON" || id === "BHAVA") return null;
if (id === "D1" || id === "MOON") return null;
if (id === "BHAVA") return "bhava";
if (id === "TRANSIT") return "transit";
return "varga";
}
@@ -6,6 +6,7 @@ import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
import type {
ChartViewFailureStatus,
ChartViewAscendantRow,
ChartViewBhavaOk,
ChartViewOk,
ChartViewPlanetRow,
ChartViewTransitOk,
@@ -236,6 +237,64 @@ function TransitTable({ transit }: { transit: ChartViewTransitOk }) {
);
}
function BhavaTables({ bhava }: { bhava: ChartViewBhavaOk }) {
return (
<>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>Bhava · {bhava.houseSystem}</caption>
<thead>
<tr>
<th scope="col">行星</th>
<th scope="col">黄道经度</th>
<th scope="col">星座宫</th>
<th scope="col">Bhava 宫</th>
<th scope="col">换宫</th>
</tr>
</thead>
<tbody>
{bhava.rows.map((row) => {
const key = planetGlyphKey(row.name);
return (
<tr key={row.name} className={row.shifted ? "is-shifted" : undefined}>
<th scope="row">
<PlanetCell name={row.name} label={row.label} abbr={key ? PLANET_ABBR[key] : "—"} />
</th>
<td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td>
<td className="chart-page-planet-mid">{row.rashiHouse}宫</td>
<td className="chart-page-planet-mid">{row.bhavaHouse}宫</td>
<td className="chart-page-planet-mid">{row.shifted ? "是" : "否"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>宫头与宫界</caption>
<thead>
<tr>
<th scope="col">宫</th>
<th scope="col">宫头</th>
<th scope="col">起点(sandhi)</th>
</tr>
</thead>
<tbody>
{bhava.cusps.map((cusp) => (
<tr key={cusp.house}>
<th scope="row">{cusp.house}宫</th>
<td className="chart-page-planet-num">{cusp.signLabel} {formatDegreeMinutes(cusp.degreeInSign)}</td>
<td className="chart-page-planet-num">{cusp.sandhiSignLabel} {formatDegreeMinutes(cusp.sandhiDegreeInSign)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
/** Chart, parameter table and boundary line, then the chart type's own table. */
function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: {
view: ChartViewOk;
@@ -287,6 +346,18 @@ function ChartTypeBody({ view, vargaId, pending, failure }: {
</VedicLayout>
);
}
if (vargaId === "BHAVA") {
const bhava = view.bhava;
if (bhava.status !== "ok") return <LayerWaiting pending={pending} failure={failure} copy={CHART_VIEW_COPY.waitingChart} />;
return (
<VedicLayout view={view} chart={bhava.chart} ariaLabel="Bhava 北印度盘 Sripati 不等宫">
<div className="chart-page-varga-table-block">
<p className="chart-page-note chart-page-table-note">{CHART_VIEW_COPY.bhavaNote}</p>
<BhavaTables bhava={bhava} />
</div>
</VedicLayout>
);
}
if (vargaId === "TRANSIT") {
const transit = view.transit;
if (transit.status !== "ok") return <LayerWaiting pending={pending} failure={failure} copy={CHART_VIEW_COPY.waitingChart} />;
+1
View File
@@ -48,6 +48,7 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie
...(layer === "varga" ? { vedic: incoming.vedic } : {}),
...(layer === "chara" ? { dasha: incoming.dasha } : {}),
...(layer === "western" ? { western: incoming.western } : {}),
...(layer === "bhava" ? { bhava: incoming.bhava } : {}),
...(layer === "transit" ? { transit: incoming.transit } : {}),
};
}
+32
View File
@@ -143,6 +143,35 @@ export const chartViewTransitOkSchema = z.object({
})).min(1),
});
/**
* Sripati Bhava Chalit: the natal bodies re-housed by the unequal house
* boundaries (sandhi) built from the birth ascendant and the sidereal MC.
*/
export const chartViewBhavaOkSchema = z.object({
status: z.literal("ok"),
houseSystem: z.literal("Sripati"),
chart: northIndianChartSchema,
rows: z.array(z.object({
name: z.string().min(1),
label: z.string().min(1),
sign: z.string().min(1),
signLabel: z.string().min(1),
degreeInSign: z.number(),
rashiHouse: z.number().int().min(1).max(12),
bhavaHouse: z.number().int().min(1).max(12),
shifted: z.boolean(),
})).min(1),
cusps: z.array(z.object({
house: z.number().int().min(1).max(12),
sign: z.string().min(1),
signLabel: z.string().min(1),
degreeInSign: z.number(),
sandhiSign: z.string().min(1),
sandhiSignLabel: z.string().min(1),
sandhiDegreeInSign: z.number(),
})).length(12),
});
export const chartViewPeriodSchema = z.object({
lord: z.string().min(1),
label: z.string().min(1),
@@ -248,6 +277,7 @@ export const chartViewOkSchema = z.object({
varga: chartViewFailureStatusSchema.optional(),
chara: chartViewFailureStatusSchema.optional(),
western: chartViewFailureStatusSchema.optional(),
bhava: chartViewFailureStatusSchema.optional(),
transit: chartViewFailureStatusSchema.optional(),
}).optional(),
status: z.literal("ok"),
@@ -270,6 +300,7 @@ export const chartViewOkSchema = z.object({
dualTrackNote: z.string().min(1),
}),
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
bhava: z.union([chartViewBhavaOkSchema, chartViewUnavailableLayerSchema]),
transit: z.union([chartViewTransitOkSchema, chartViewUnavailableLayerSchema]),
accountId: z.string().min(1).optional(),
profileFingerprint: z.string().min(1).optional(),
@@ -298,6 +329,7 @@ export type ChartViewResponse = z.infer<typeof chartViewResponseSchema>;
export type ChartViewUnavailableLayer = z.infer<typeof chartViewUnavailableLayerSchema>;
export type ChartViewWesternOk = z.infer<typeof chartViewWesternOkSchema>;
export type ChartViewTransitOk = z.infer<typeof chartViewTransitOkSchema>;
export type ChartViewBhavaOk = z.infer<typeof chartViewBhavaOkSchema>;
export type ChartViewMoonChart = z.infer<typeof chartViewMoonChartSchema>;
export type ChartViewVarga = z.infer<typeof chartViewVargaSchema>;
export type ChartViewVargaRow = z.infer<typeof chartViewVargaRowSchema>;
+1 -1
View File
@@ -2,7 +2,7 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000;
export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000;
const ENGINE_CACHE_MAX_ENTRIES = 200;
export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "transit"] as const;
export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "bhava", "transit"] as const;
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
export type EngineCallResult =
+1
View File
@@ -191,6 +191,7 @@ export const CHART_VIEW_COPY = {
chartTypeSheetTitle: "星盘类型",
chartTypeMenu: "全部盘型",
chartTypeSheetClose: "关闭星盘类型",
bhavaNote: "宫制 Sripati:用出生时刻的上升和天顶把四个象限各分三份,行星按宫界重新归宫。星座度数和本命盘相同。",
transitNote: "以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。",
precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。",
unauthenticated: "请先登录后再看星盘。",
+12 -1
View File
@@ -167,7 +167,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
return enginePayload(result);
};
const transitDate = input.asOf.slice(0, 10);
const [varga, chara, western, transit] = await Promise.all([
const [varga, chara, western, bhava, transit] = await Promise.all([
layers.has("varga")
? callEngine(input.postEngine, "/api/varga_full", {
...followUp,
@@ -184,6 +184,16 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
layers.has("western")
? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western"))
: Promise.resolve(null),
// Sripati needs the birth moment for the sidereal MC (BUG-1077); followUp
// carries it. `house_system: "P"` in the birth payload is the western
// wheel's and must not reach this call.
layers.has("bhava")
? callEngine(input.postEngine, "/api/bhava_chalit", {
...followUp,
house_system: "sripati",
mode: "compare",
}).then(layerPayload("bhava"))
: Promise.resolve(null),
layers.has("transit")
? callEngine(
input.postEngine,
@@ -206,6 +216,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
varga,
chara,
western,
bhava,
transit,
profile,
asOf: input.asOf,
+90 -1
View File
@@ -28,6 +28,7 @@ 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,
@@ -57,6 +58,7 @@ export type ChartViewEngineBundle = {
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;
@@ -481,6 +483,92 @@ function mapTransit(
};
}
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 : [];
@@ -699,7 +787,7 @@ function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string
}
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
const { chart, varga, chara, western, transit, profile, asOf } = bundle;
const { chart, varga, chara, western, bhava, transit, profile, asOf } = bundle;
const planets = asPlanetMap(chart.planets);
const moon = planets.Moon ?? {};
const ascendant = record(chart.ascendant);
@@ -753,6 +841,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
},
western: mapWestern(western, ayanamsaDisplay),
bhava: mapBhava(bhava, chart),
transit: mapTransit(transit, chart, profile, asOf),
};
return chartViewOkSchema.parse(view);
+1
View File
@@ -214,6 +214,7 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<
...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}),
dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) },
...(prior.western.status === "ok" ? { western: prior.western } : {}),
...(prior.bhava.status === "ok" ? { bhava: prior.bhava } : {}),
...(prior.transit.status === "ok" ? { transit: prior.transit } : {}),
} };
}