diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 6f8ceff8..fcd659c3 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -6,6 +6,9 @@ import type { ChartViewAscendantRow, ChartViewOk, ChartViewPlanetRow, + ChartViewVarga, + ChartViewVargaAscendantRow, + ChartViewVargaRow, } from "@/lib/chart-view-contract"; import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels"; import { @@ -19,6 +22,7 @@ import { import { formatDegreeMinutes } from "./chart-format"; import { ChartParamTable } from "./chart-param-table"; import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; +import { VARGA_DIGNITY_LABEL } from "@/lib/chart-view-varga"; import { ChartPageSkeleton } from "./chart-page-skeleton"; /** The same coloured symbol the chart draws, so a row and a house read as one body. */ @@ -86,6 +90,99 @@ function AscendantRow({ row }: { row: ChartViewAscendantRow }) { ); } +/** + * The natal eight-column table. Everything the chart stopped drawing lands + * here: the chart carries symbols, this table carries the numbers behind them. + */ +function NatalTable({ view, caption }: { view: ChartViewOk; caption: string }) { + return ( +
+ + + + + + + + + + + + + + + + {view.vedic.ascendant && } + {view.vedic.planets.map((planet) => ( + + ))} + +
{caption}
行星黄道经度速度宫位星宿宿主pada状态
+
+ ); +} + +function VargaRow({ row }: { row: ChartViewVargaRow }) { + const key = planetGlyphKey(row.name); + return ( + + + + {row.retrograde ? 逆 : null} + + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} + {row.house}宫 + {row.natalSignLabel} + {row.dignity ? VARGA_DIGNITY_LABEL[row.dignity] : "—"} + {row.vargottama ? "同位" : "—"} + + ); +} + +function VargaAscendantRow({ row }: { row: ChartViewVargaAscendantRow }) { + return ( + + + + + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} + 1宫 + {row.natalSignLabel} + — + {row.vargottama ? "同位" : "—"} + + ); +} + +/** + * A divisional chart's own table (BUG-1076). Six columns, not the natal eight: + * nakshatra, its lord and pada are properties of the D1 longitude and would be + * wrong data here, so they are not shown at all. + */ +export function VargaTable({ varga }: { varga: ChartViewVarga }) { + return ( +
+ + + + + + + + + + + + + + {varga.ascendant ? : null} + {(varga.rows ?? []).map((row) => )} + +
{varga.id} · {varga.meaning}
行星分盘星座宫位本命星座庙旺同位
+
+ ); +} + export function ChartVedicTab({ view, vargaId, @@ -138,31 +235,11 @@ export function ChartVedicTab({ chart can and cannot be read for. */}

{view.vedic.boundary}

- {/* Everything the chart stopped drawing lands here: the chart carries - symbols, this table carries the numbers behind them. */} -
- - - - - - - - - - - - - - - - {view.vedic.ascendant && } - {view.vedic.planets.map((planet) => ( - - ))} - -
{varga.id} · {varga.meaning}
行星黄道经度速度宫位星宿宿主pada状态
-
+ {varga.id === "D1" || !varga.rows ? ( + + ) : ( + + )} )} diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 18824e6c..17a9c207 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -66,12 +66,45 @@ export const chartViewAscendantRowSchema = z.object({ nakshatraLord: z.string().min(1).nullable(), }); +/** + * One body in a divisional chart's own table (BUG-1076). Sign, in-varga degree + * and house are the engine's divisional values; `natalSign` is the D1 sign it + * came from; retrograde is the natal direction because a varga does not change + * motion. Nakshatra and pada are deliberately absent: they belong to the D1 + * longitude and would be wrong data in a divisional table. + */ +export const chartViewVargaRowSchema = 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(), + house: z.number().int().min(1).max(12), + natalSign: z.string().min(1), + natalSignLabel: z.string().min(1), + dignity: z.enum(["exalted", "own", "debilitated"]).nullable(), + vargottama: z.boolean(), + retrograde: z.boolean(), +}); + +export const chartViewVargaAscendantRowSchema = z.object({ + sign: z.string().min(1), + signLabel: z.string().min(1), + degreeInSign: z.number(), + natalSign: z.string().min(1), + natalSignLabel: z.string().min(1), + vargottama: z.boolean(), +}); + export const chartViewVargaSchema = z.object({ id: z.string().regex(/^D\d{1,3}$/), title: z.string().min(1), meaning: z.string().min(1), division: z.number().int().positive(), chart: northIndianChartSchema, + // D2 and up only. D1 keeps the natal eight-column table (`vedic.planets`). + ascendant: chartViewVargaAscendantRowSchema.optional(), + rows: z.array(chartViewVargaRowSchema).optional(), }); export const chartViewPeriodSchema = z.object({ @@ -226,6 +259,8 @@ export type ChartViewResponse = z.infer; export type ChartViewUnavailableLayer = z.infer; export type ChartViewWesternOk = z.infer; export type ChartViewVarga = z.infer; +export type ChartViewVargaRow = z.infer; +export type ChartViewVargaAscendantRow = z.infer; export type ChartViewPlanetRow = z.infer; export type NorthIndianChart = z.infer; export type ChartViewAscendantRow = z.infer; diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index c2b70b8d..4d11c01c 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -22,11 +22,14 @@ import { signZh, } from "./chart-view-labels.ts"; import { traditionalSymbol } from "./chart-view-symbols.ts"; +import { vargaDignity } from "./chart-view-varga.ts"; import { chartViewOkSchema, type ChartViewOk, type ChartViewUnavailableLayer, type ChartViewVarga, + type ChartViewVargaAscendantRow, + type ChartViewVargaRow, type ChartViewWesternOk, type NorthIndianChart, } from "./chart-view-contract.ts"; @@ -190,6 +193,58 @@ function vargaKey(division: number, result: Record): Record; 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, + natalRetro: ReadonlyMap, + 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, @@ -234,9 +289,14 @@ function buildVargas( vargaPacket: Record | null, ): ChartViewVarga[] { const natalRetro = new Map(); + const natalPlanetSigns = new Map(); 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) { @@ -245,12 +305,14 @@ function buildVargas( 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; } diff --git a/frontend/src/lib/chart-view-varga.ts b/frontend/src/lib/chart-view-varga.ts new file mode 100644 index 00000000..5265feff --- /dev/null +++ b/frontend/src/lib/chart-view-varga.ts @@ -0,0 +1,35 @@ +import tables from "./vedic-chart-tables.json"; + +export type VargaDignity = "exalted" | "own" | "debilitated"; + +const EXALTATION: Readonly> = tables.exaltation; +const DEBILITATION: Readonly> = tables.debilitation; +const SIGN_LORDS: Readonly> = tables.signLords; +const NODE_OWN_SIGN: Readonly> = tables.nodeOwnSign; +const VARGA_MEANING: Readonly> = tables.vargaMeaning; + +/** + * Sign dignity of a body inside a divisional chart. + * + * Same precedence as `jyotish_engine._get_dignity_level` (exalted, then own + * sign, then debilitated) without the parts that need a D1 degree or the whole + * chart: no moolatrikona band, no neecha bhanga, no friendship. The tables are + * the engine's; `tests/test_chart_page_vedic_tables_contract.py` pins them. + */ +export function vargaDignity(planet: string, sign: string): VargaDignity | null { + if (EXALTATION[planet] === sign) return "exalted"; + if (SIGN_LORDS[sign] === planet || NODE_OWN_SIGN[planet] === sign) return "own"; + if (DEBILITATION[planet] === sign) return "debilitated"; + return null; +} + +export const VARGA_DIGNITY_LABEL: Record = { + exalted: "入旺", + own: "自宫", + debilitated: "落陷", +}; + +/** The engine's own one-line use of a D-number chart (`VargaType.meaning`). */ +export function vargaMeaning(division: number): string | null { + return VARGA_MEANING[String(division)] ?? null; +} diff --git a/frontend/src/lib/vedic-chart-tables.json b/frontend/src/lib/vedic-chart-tables.json new file mode 100644 index 00000000..271f5113 --- /dev/null +++ b/frontend/src/lib/vedic-chart-tables.json @@ -0,0 +1,61 @@ +{ + "_comment": "Chart-page reference tables. Mirrors jyotish_engine.SIGN_LORDS / EXALTATION / DEBILITATION, the Rahu-Virgo / Ketu-Pisces own-sign rule in jyotish_engine._get_dignity_level, and divisional_charts_extended.VargaType meanings. tests/test_chart_page_vedic_tables_contract.py fails if either side drifts.", + "vargaMeaning": { + "1": "本命盘", + "2": "财富", + "3": "兄弟姐妹", + "4": "财产/运气", + "5": "名声/权力", + "6": "健康/敌人", + "7": "子女", + "8": "突发事件", + "9": "配偶/灵性", + "10": "事业", + "11": "破坏/转化", + "12": "父母", + "16": "交通工具/舒适", + "20": "灵性修行", + "24": "教育/学习", + "27": "力量/弱点", + "30": "不幸/困难", + "40": "吉凶效果", + "45": "全面判断", + "60": "前世业力" + }, + "signLords": { + "Aries": "Mars", + "Taurus": "Venus", + "Gemini": "Mercury", + "Cancer": "Moon", + "Leo": "Sun", + "Virgo": "Mercury", + "Libra": "Venus", + "Scorpio": "Mars", + "Sagittarius": "Jupiter", + "Capricorn": "Saturn", + "Aquarius": "Saturn", + "Pisces": "Jupiter" + }, + "exaltation": { + "Sun": "Aries", + "Moon": "Taurus", + "Mars": "Capricorn", + "Mercury": "Virgo", + "Jupiter": "Cancer", + "Venus": "Pisces", + "Saturn": "Libra" + }, + "debilitation": { + "Sun": "Libra", + "Moon": "Scorpio", + "Mars": "Cancer", + "Mercury": "Pisces", + "Jupiter": "Capricorn", + "Venus": "Virgo", + "Saturn": "Aries" + }, + "nodeOwnSign": { + "Rahu": "Virgo", + "Ketu": "Pisces" + } +} \ No newline at end of file diff --git a/frontend/tests/chart-varga-table.test.tsx b/frontend/tests/chart-varga-table.test.tsx new file mode 100644 index 00000000..3881230c --- /dev/null +++ b/frontend/tests/chart-varga-table.test.tsx @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import test from "node:test"; + +import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; + +Object.assign(globalThis, { React }); + +// Real engine output (see the fixture's sourceCommand); nothing here is hand-shaped. +const golden = JSON.parse( + readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"), +) as { + chart: Record; + varga_full: { result: Record; planets: Record> }> }; + chara: Record; +}; + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed", +}; + +async function vargaView(): Promise { + const result = await assembleChartView({ + userId: "user-1", + profile, + asOf: "2026-09-15", + layers: ["varga"], + postEngine: async (path) => { + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full as unknown as Record }; + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.equal(result.body.status, "ok"); + return result.body as ChartViewOk; +} + +function render(view: ChartViewOk, vargaId: string): string { + return renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId, onSelectVarga: () => {} })); +} + +/** The table row whose header cell names this body. */ +function row(markup: string, label: string): string { + const tbody = markup.match(/([\s\S]*?)<\/tbody>/)?.[1] ?? ""; + const hit = [...tbody.matchAll(/([\s\S]*?)<\/tr>/g)].map((match) => match[1]!).find((cells) => { + const header = cells.match(/([\s\S]*?)<\/th>/)?.[1] ?? ""; + return header.includes(label); + }); + assert.ok(hit, `no row for ${label}`); + return hit.replace(//g, ""); +} + +function degreeMinutes(degree: number): string { + const whole = Math.floor(degree); + const minutes = Math.floor((degree - whole) * 60); + return `${whole}°${String(minutes).padStart(2, "0")}′`; +} + +// BUG-1076: the D9 caption changed but every row stayed D1. +test("a divisional chart's table carries that chart's signs, degrees and houses", async () => { + const view = await vargaView(); + const d9 = golden.varga_full.result.D9_Navamsa!; + const d1Markup = render(view, "D1"); + const d9Markup = render(view, "D9"); + + const sunD9 = row(d9Markup, "太阳"); + const moonD9 = row(d9Markup, "月亮"); + assert.match(sunD9, /天秤/, "D9 Sun is in Libra"); + assert.ok(sunD9.includes(degreeMinutes(Number(d9.planets.Sun!.degree))), "D9 Sun degree is the in-varga degree"); + assert.match(moonD9, /双鱼/, "D9 Moon is in Pisces"); + assert.match(moonD9, />4宫 { + const view = await vargaView(); + const markup = render(view, "D1"); + const table = markup.match(/
[\s\S]*?<\/table><\/div>/)?.[0]; + // Rendered from the baseline commit 5dc977ef with the same golden. + const baseline = readFileSync(new URL("./fixtures/chart-d1-table-baseline.html", import.meta.url), "utf8"); + assert.equal(table, baseline); +}); + +test("divisional rows carry engine dignity and vargottama, and drop nakshatra columns", async () => { + const view = await vargaView(); + const d9 = view.vedic.vargas.find((item) => item.id === "D9"); + assert.ok(d9?.rows); + const saturn = d9.rows.find((item) => item.name === "Saturn"); + // D1 Saturn is in Capricorn and D9 Saturn is in Capricorn: own sign, vargottama. + assert.equal(saturn?.sign, "Capricorn"); + assert.equal(saturn?.dignity, "own"); + assert.equal(saturn?.vargottama, true); + const sun = d9.rows.find((item) => item.name === "Sun"); + assert.equal(sun?.sign, "Libra"); + assert.equal(sun?.dignity, "debilitated"); + assert.equal(sun?.vargottama, false); + const markup = render(view, "D9"); + const header = markup.match(/([\s\S]*?)<\/thead>/)?.[1] ?? ""; + for (const column of ["行星", "分盘星座", "宫位", "本命星座", "庙旺", "同位"]) assert.match(header, new RegExp(`>${column}<`)); + assert.doesNotMatch(header, /星宿|宿主|pada/); + assert.match(row(markup, "土星"), />自宫<[\s\S]*>同位落陷 { + assert.equal(vargaDignity("Sun", "Aries"), "exalted"); + assert.equal(vargaDignity("Sun", "Leo"), "own"); + assert.equal(vargaDignity("Sun", "Libra"), "debilitated"); + assert.equal(vargaDignity("Mercury", "Virgo"), "exalted", "exaltation wins over own sign"); + assert.equal(vargaDignity("Rahu", "Virgo"), "own"); + assert.equal(vargaDignity("Ketu", "Pisces"), "own"); + assert.equal(vargaDignity("Rahu", "Taurus"), null, "the engine has no node exaltation"); + assert.equal(vargaDignity("Moon", "Leo"), null); +}); + +test("varga meanings come from the engine table including D40, D45 and D60", () => { + assert.equal(vargaMeaning(9), "配偶/灵性"); + assert.equal(vargaMeaning(45), "全面判断"); + assert.equal(vargaMeaning(60), "前世业力"); + assert.equal(vargaMeaning(81), null); +}); diff --git a/frontend/tests/fixtures/chart-d1-table-baseline.html b/frontend/tests/fixtures/chart-d1-table-baseline.html new file mode 100644 index 00000000..6966afd8 --- /dev/null +++ b/frontend/tests/fixtures/chart-d1-table-baseline.html @@ -0,0 +1 @@ +
D1 · 本命盘
行星黄道经度速度宫位星宿宿主pada状态
As 上升 As狮子 29°23′顺1宫————
☉ 太阳 Su双子 1°32′顺11宫Mrigashira♂︎3入友(Friendly Sign)
☽ 月亮 Mo水瓶 18°38′顺7宫Shatabhisha☊4入友(Friendly Sign)
♂︎ 火星 Ma双鱼 18°31′顺8宫Revati☿1极友(Great Friend)
☿ 水星 Me金牛 12°50′顺10宫Rohini☽1极友(Great Friend)
♃ 木星 Ju双子 23°32′顺11宫Punarvasu♃2中性(Neutral)
♀︎ 金星 Ve白羊 26°06′顺9宫Bharani♀︎4入友(Friendly Sign)
♄ 土星 Sa摩羯 1°46′逆6宫Uttara Ashadha☉2入庙(Own Sign) · 逆行
☊ 罗睺 Ra摩羯 17°26′逆6宫Shravana☽3中性(Neutral) · 逆行
☋ 计都 Ke巨蟹 17°26′逆12宫Ashlesha☿1极敌(Great Enemy) · 逆行
\ No newline at end of file diff --git a/tests/test_chart_page_vedic_tables_contract.py b/tests/test_chart_page_vedic_tables_contract.py new file mode 100644 index 00000000..a3473f2d --- /dev/null +++ b/tests/test_chart_page_vedic_tables_contract.py @@ -0,0 +1,58 @@ +"""The chart page's dignity and varga-meaning tables must be the engine's. + +`frontend/src/lib/vedic-chart-tables.json` is read by the chart-page mapper so +the divisional tables can show 入旺 / 自宫 / 落陷 without a second hand-written +rule set. This test pins it to the constants `jyotish_engine._get_dignity_level` +uses and to `divisional_charts_extended.VargaType`. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +import divisional_charts_extended # noqa: E402 +import jyotish_engine # noqa: E402 + +TABLES = json.loads((ROOT / "frontend/src/lib/vedic-chart-tables.json").read_text(encoding="utf-8")) +PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"] + + +def _table_dignity(planet: str, sign: str) -> str | None: + """Same precedence as `vargaDignity` in frontend/src/lib/chart-view-varga.ts.""" + if TABLES["exaltation"].get(planet) == sign: + return "EXALTED" + if TABLES["signLords"].get(sign) == planet or TABLES["nodeOwnSign"].get(planet) == sign: + return "OWN_SIGN" + if TABLES["debilitation"].get(planet) == sign: + return "DEBILITATED" + return None + + +def test_tables_equal_engine_constants() -> None: + assert TABLES["signLords"] == jyotish_engine.SIGN_LORDS + assert TABLES["exaltation"] == jyotish_engine.EXALTATION + assert TABLES["debilitation"] == jyotish_engine.DEBILITATION + + +def test_every_planet_sign_pair_matches_engine_dignity_level() -> None: + for planet in PLANETS: + for sign in jyotish_engine.SIGN_LORDS: + # No degree and no chart context: the varga table shows the sign + # dignity only (no moolatrikona band, no neecha bhanga, no friendship). + engine = jyotish_engine._get_dignity_level(planet, sign) + expected = engine if engine in {"EXALTED", "OWN_SIGN", "DEBILITATED"} else None + if engine == "NEECHA_BHANGA": + expected = "DEBILITATED" + assert _table_dignity(planet, sign) == expected, (planet, sign, engine) + + +def test_varga_meanings_equal_engine_varga_types() -> None: + engine = {str(v.division): v.meaning for v in divisional_charts_extended.VargaType if v.division <= 60} + assert TABLES["vargaMeaning"] == engine + for division in ("40", "45", "60"): + assert division in TABLES["vargaMeaning"]