fix(report,chart): blank columns — drop Kranti, split Ashtakavarga house SAV, ascendant nakshatra, engine varga dignity, no duplicate charts (BUG-1199..1203)
Independent Staging Quality Gate / validate (push) Successful in 15m27s
Independent Staging Quality Gate / publish (push) Successful in 3m42s

- 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
This commit is contained in:
Jesse_Chen
2026-10-03 09:11:15 +08:00
co-authored by Claude Opus 5.5
parent 0e856f9cb6
commit ae18952e51
28 changed files with 1011 additions and 346 deletions
@@ -78,7 +78,8 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
}
/**
* The ascendant is a chart point: the engine returns no nakshatra for it.
* The ascendant is a chart point: nakshatra, lord and pada come from the
* engine (BUG-1201); it has no dignity, so the status cell stays empty.
* `house` is 1 except on the Moon chart, which counts from the Moon.
*/
function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) {
@@ -566,7 +566,7 @@ export function PersonalReportPage({ reportId, initialLanguage = "zh" }: { repor
<div className="personal-report-reader-body" lang={shown === "en" ? "en" : "zh-CN"}>
{/* Siblings remount per edition under distinct keys; a shared key left the old edition on screen (BUG-1126). */}
<PersonalReportMarkdownView key={`markdown-${shown}`} language={shown} markdown={markdown} drawnThrough={drawnThrough} />
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} language={shown} />}
{state.calculationCharts && <ReportCalculationCharts charts={state.calculationCharts} markdown={markdown} language={shown} />}
{factTables && <ReportFactTables key={`facts-${shown}`} language={shown} tables={factTables} />}
</div>
</div>
@@ -1,22 +1,34 @@
"use client";
import { memo } from "react";
import { memo, useMemo } from "react";
import type { ChartV1, ReportDocumentV2 } from "@/lib/personal-report-contract";
import { fencedReportChartIds } from "@/lib/report-chart-block";
import { hasRealChartData, VedicChartSvg } from "./vedic-chart-svg";
import type { ReportLanguage } from "@/lib/report-language";
const COPY = {
zh: { heading: "分盘核对", note: "原始计算供核对,分盘对出生时间敏感,不单独增加结论的确定性。" },
en: { heading: "Divisional charts for checking", note: "Raw calculations for checking. Divisional charts are sensitive to birth time and do not by themselves make a conclusion more certain." },
zh: { heading: "分盘核对" },
en: { heading: "Divisional charts for checking" },
} as const;
export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts, language = "zh" }: { charts: ReportDocumentV2["charts"]; language?: ReportLanguage }) {
/**
* Structured charts the report body did not already draw (BUG-1203): the same
* rule as the export. A current report draws every chart in its body, so this
* renders nothing; an older report without chart fences still gets its charts.
* The birth-time caveat lives under the body's chart heading.
*/
export const ReportCalculationCharts = memo(function ReportCalculationCharts({ charts, markdown, language = "zh" }: {
charts: ReportDocumentV2["charts"];
/** The body shown on the page, whose chart fences are already drawn. */
markdown: string;
language?: ReportLanguage;
}) {
const copy = COPY[language];
const actual = charts.filter(chart => hasRealChartData(chart as ChartV1));
const drawn = useMemo(() => fencedReportChartIds(markdown), [markdown]);
const actual = charts.filter(chart => hasRealChartData(chart as ChartV1) && !drawn.has(chart.id));
if (!actual.length) return null;
return <section className="personal-report-section" aria-label={copy.heading}>
<h2>{copy.heading}</h2>
<p>{copy.note}</p>
<div className="personal-report-chart-grid">
{actual.map(chart => <article className="personal-report-chart-card" key={chart.id}>
<h3>{language === "en" ? `${chart.id} chart` : chart.title}</h3>
+2 -1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { CHART_BIRTH_FAILURE_REASONS } from "./chart-birth-truth.ts";
import { PLANET_GLYPH_KEYS } from "./planet-glyphs.ts";
import { VARGA_DIGNITY_LEVELS } from "./chart-view-varga.ts";
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
@@ -82,7 +83,7 @@ export const chartViewVargaRowSchema = z.object({
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(),
dignity: z.enum(VARGA_DIGNITY_LEVELS).nullable(),
vargottama: z.boolean(),
retrograde: z.boolean(),
});
+10 -10
View File
@@ -23,7 +23,7 @@ import {
signZh,
} from "./chart-view-labels.ts";
import { traditionalSymbol } from "./chart-view-symbols.ts";
import { vargaDignity } from "./chart-view-varga.ts";
import { vargaDignityLevel } from "./chart-view-varga.ts";
import { transitsFromEngine } from "./ephemeris-view.ts";
import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts";
import {
@@ -209,9 +209,8 @@ function houseFromSigns(sign: string, ascSign: string): number {
/**
* 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`).
* 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,
@@ -236,7 +235,7 @@ function vargaRows(
house: engineHouse !== null && engineHouse >= 1 && engineHouse <= 12 ? Math.trunc(engineHouse) : houseFromSigns(sign, ascSign),
natalSign,
natalSignLabel: signZh(natalSign),
dignity: vargaDignity(name, sign),
dignity: vargaDignityLevel(planet.dignity_level),
vargottama: sign === natalSign,
retrograde: natalRetro.get(name) ?? false,
});
@@ -323,9 +322,10 @@ function buildVargas(
chart: mapped.chart,
...(table ? {
...table,
// A null dignity or false same-sign result is still a valid result.
// Availability follows validated source rows, never positive hits.
supportsDignity: table.rows.length > 0,
// 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),
} : {}),
});
@@ -383,9 +383,9 @@ function planetRows(chart: Record<string, unknown>, vargaPacket: Record<string,
}
/**
* 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.
* 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);
+26 -24
View File
@@ -1,33 +1,35 @@
import tables from "./vedic-chart-tables.json";
export type VargaDignity = "exalted" | "own" | "debilitated";
/**
* The engine's dignity levels (`jyotish_engine._get_dignity_level`). A
* divisional row carries the level `/api/varga_full` computed with
* `varga_dignity_level`, the Vimsopaka function; the page never recomputes it.
*/
export const VARGA_DIGNITY_LEVELS = [
"EXALTED",
"MOOLATRIKONA",
"OWN_SIGN",
"GREAT_FRIEND",
"FRIEND",
"NEUTRAL",
"ENEMY",
"GREAT_ENEMY",
"DEBILITATED",
"NEECHA_BHANGA",
] as const;
export type VargaDignity = (typeof VARGA_DIGNITY_LEVELS)[number];
const EXALTATION: Readonly<Record<string, string>> = tables.exaltation;
const DEBILITATION: Readonly<Record<string, string>> = tables.debilitation;
const SIGN_LORDS: Readonly<Record<string, string>> = tables.signLords;
const NODE_OWN_SIGN: Readonly<Record<string, string>> = tables.nodeOwnSign;
const VARGA_MEANING: Readonly<Record<string, string>> = 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;
}
/** Same wording as the D1 "状态" column (`jyotish_engine.DIGNITY_LABELS`). */
export const VARGA_DIGNITY_LABEL: Readonly<Record<VargaDignity, string>> = tables.dignityLabels;
export const VARGA_DIGNITY_LABEL: Record<VargaDignity, string> = {
exalted: "入旺",
own: "自宫",
debilitated: "落陷",
};
export function vargaDignityLevel(value: unknown): VargaDignity | null {
return typeof value === "string" && (VARGA_DIGNITY_LEVELS as readonly string[]).includes(value)
? value as VargaDignity
: null;
}
/** The engine's own one-line use of a D-number chart (`VargaType.meaning`). */
export function vargaMeaning(division: number): string | null {
+13
View File
@@ -203,6 +203,19 @@ export function toNorthIndianChart(block: ReportChartBlock, language: ReportLang
};
}
/**
* Ids of the valid chart fences a report body draws. The reader page and the
* export both skip a structured chart whose id is already here (BUG-1203).
*/
export function fencedReportChartIds(markdown: string): Set<string> {
const ids = new Set<string>();
for (const match of markdown.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) {
const block = parseReportChartBlock(match[1]);
if (block) ids.add(block.id);
}
return ids;
}
export function stripReportChartBlocks(markdown: string): string {
const stripped = markdown.replace(FENCE_RE, "\n\n");
return stripped.replace(/\n{3,}/g, "\n\n");
+2 -3
View File
@@ -1,7 +1,7 @@
import type { ReportDocumentV2 } from "./personal-report-contract";
import { reportChartExportSvg } from "./report-chart-export";
import { buildLongformOutline, personalReportMarkdownFilename } from "./personal-report-longform-outline";
import { chartAriaLabel, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
import { chartAriaLabel, fencedReportChartIds, parseReportChartBlock, stripReportChartBlocks, toNorthIndianChart, type ReportChartBlock } from "./report-chart-block";
import type { ReportFactTable } from "./report-fact-table-schema";
import { factCellText, factStatusLabel } from "./report-fact-table-display";
import type { ReportLanguage } from "./report-language";
@@ -71,12 +71,11 @@ export function buildReportExportBlocks({ markdown, charts = [], factTables = []
for (const section of outline.sections) {
add(`body:${section.id}`, "body", section.title, stripReportChartBlocks(section.sourceMarkdown));
}
const chartIds = new Set<string>();
const chartIds = fencedReportChartIds(projected);
for (const match of projected.matchAll(/```jyotish-chart\r?\n([\s\S]*?)```/g)) {
const block = parseReportChartBlock(match[1]);
if (!block) continue;
const occurrence = blocks.filter(item => item.group === "chart" && item.id.startsWith(`chart:${block.id}:`)).length + 1;
chartIds.add(block.id);
// Only the immediately preceding SVG belongs to this occurrence (D9 may repeat).
// The public projection has already rejected unsafe SVG; never recover raw HTML.
const preceding = projected.slice(0, match.index);
+12 -36
View File
@@ -1,5 +1,5 @@
{
"_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.",
"_comment": "Engine tables the chart page reads. vargaMeaning = divisional_charts_extended.VargaType meaning; dignityLabels = jyotish_engine.DIGNITY_LABELS (the D1 status wording). Pinned by tests/test_chart_page_vedic_tables_contract.py.",
"vargaMeaning": {
"1": "本命盘",
"2": "财富",
@@ -22,40 +22,16 @@
"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"
"dignityLabels": {
"EXALTED": "入旺(Exalted)",
"MOOLATRIKONA": "本垣(Moolatrikona)",
"OWN_SIGN": "入庙(Own Sign)",
"GREAT_FRIEND": "极友(Great Friend)",
"FRIEND": "入友(Friendly Sign)",
"NEUTRAL": "中性(Neutral)",
"ENEMY": "入敌(Enemy Sign)",
"GREAT_ENEMY": "极敌(Great Enemy)",
"DEBILITATED": "落陷(Debilitated)",
"NEECHA_BHANGA": "落陷取消(Neecha Bhanga)"
}
}