Files
Jyotisha/frontend/src/lib/report-chart-block.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

223 lines
5.8 KiB
TypeScript

import { z } from "zod";
import { PLANET_ABBR, PLANET_GLYPH_KEYS, type PlanetGlyphKey } from "./planet-glyphs";
import type { ReportLanguage } from "./report-language";
const SIGNS = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
] as const;
const PLANETS = [
"Sun",
"Moon",
"Mars",
"Mercury",
"Jupiter",
"Venus",
"Saturn",
"Rahu",
"Ketu",
] as const;
const PLANET_LABEL: Record<(typeof PLANETS)[number], string> = {
Sun: "日",
Moon: "月",
Mars: "火",
Mercury: "水",
Jupiter: "木",
Venus: "金",
Saturn: "土",
Rahu: "罗",
Ketu: "计",
};
const SIGN_NUMBER: Record<string, number> = {
Aries: 1,
Taurus: 2,
Gemini: 3,
Cancer: 4,
Leo: 5,
Virgo: 6,
Libra: 7,
Scorpio: 8,
Sagittarius: 9,
Capricorn: 10,
Aquarius: 11,
Pisces: 12,
白羊: 1,
金牛: 2,
双子: 3,
巨蟹: 4,
狮子: 5,
处女: 6,
天秤: 7,
天蝎: 8,
射手: 9,
摩羯: 10,
水瓶: 11,
双鱼: 12,
白羊座: 1,
金牛座: 2,
双子座: 3,
巨蟹座: 4,
狮子座: 5,
处女座: 6,
天秤座: 7,
天蝎座: 8,
射手座: 9,
摩羯座: 10,
水瓶座: 11,
双鱼座: 12,
};
const FENCE_RE = /(?:\r?\n[ \t]*)?```jyotish-chart\r?\n[\s\S]*?```(?:\r?\n[ \t]*)?/g;
export const reportChartBlockSchema = z.strictObject({
version: z.literal(1),
id: z.string().regex(/^(D\d{1,3}|MOON)$/),
title: z.string().max(120),
layout: z.literal("north"),
ascendant: z.strictObject({
sign: z.enum(SIGNS),
degree: z.number().gte(0).lt(30),
}),
planets: z.array(z.strictObject({
name: z.enum(PLANETS),
sign: z.enum(SIGNS),
degree: z.number().gte(0).lt(30),
retrograde: z.boolean(),
})).max(9),
});
export type ReportChartBlock = z.infer<typeof reportChartBlockSchema>;
/**
* `occupants` stays the display string — it is what `aria-label`, the house
* `<details>` table and screen readers read out. `occupantGlyphs` is the
* structured twin the chart draws from, so the renderer never has to read a
* body or its direction back out of formatted text. Routes whose house table
* arrives as engine/agent text (the rectification board, the personal report
* document) omit it and are parsed instead.
*/
export type NorthIndianChartModel = {
houses: {
houseNumber: number;
sign: string;
occupants: string[];
occupantGlyphs?: { key: PlanetGlyphKey; retrograde: boolean; degree: number }[];
}[];
planets?: { name: string; retrograde: boolean }[];
};
export function parseReportChartBlock(source: string): ReportChartBlock | null {
try {
return reportChartBlockSchema.parse(JSON.parse(source));
} catch {
return null;
}
}
function integerDegree(degree: number): number {
const value = Math.floor(degree);
if (value >= 30) return 29;
if (value < 0) return 0;
return value;
}
export function planetDisplayLabel(
name: (typeof PLANETS)[number],
degree: number,
retrograde: boolean,
language: ReportLanguage = "zh",
): string {
if (language === "en") return `${PLANET_ABBR[PLANET_GLYPH_KEY[name]]}${retrograde ? " (R)" : ""} ${integerDegree(degree)}°`;
const body = PLANET_LABEL[name];
const suffix = retrograde ? "逆" : "";
return `${body}${suffix} ${integerDegree(degree)}°`;
}
export function signOrdinal(sign: string): number | null {
return SIGN_NUMBER[sign] ?? null;
}
export function chartAriaLabel(id: ReportChartBlock["id"], language: ReportLanguage = "zh"): string {
if (language === "en") return id === "MOON" ? "Moon reference chart" : `${id} North Indian chart`;
return id === "MOON" ? "月亮参考盘" : `${id} 北印度盘`;
}
const PLANET_GLYPH_KEY: Record<(typeof PLANETS)[number], PlanetGlyphKey> = {
Sun: "sun",
Moon: "moon",
Mars: "mars",
Mercury: "mercury",
Jupiter: "jupiter",
Venus: "venus",
Saturn: "saturn",
Rahu: "rahu",
Ketu: "ketu",
};
export function isPlanetGlyphKey(value: string): value is PlanetGlyphKey {
return (PLANET_GLYPH_KEYS as readonly string[]).includes(value);
}
export function toNorthIndianChart(block: ReportChartBlock, language: ReportLanguage = "zh"): NorthIndianChartModel {
const ascIndex = SIGNS.indexOf(block.ascendant.sign);
const occupantsByHouse: string[][] = Array.from({ length: 12 }, () => []);
const glyphsByHouse: NonNullable<NorthIndianChartModel["houses"][number]["occupantGlyphs"]>[] =
Array.from({ length: 12 }, () => []);
for (const planet of block.planets) {
const signIndex = SIGNS.indexOf(planet.sign);
const houseNumber = ((signIndex - ascIndex + 12) % 12) + 1;
occupantsByHouse[houseNumber - 1].push(
planetDisplayLabel(planet.name, planet.degree, planet.retrograde, language),
);
glyphsByHouse[houseNumber - 1].push({
key: PLANET_GLYPH_KEY[planet.name],
retrograde: planet.retrograde,
degree: planet.degree,
});
}
return {
houses: Array.from({ length: 12 }, (_, index) => ({
houseNumber: index + 1,
sign: SIGNS[(ascIndex + index + 12) % 12],
occupants: occupantsByHouse[index],
occupantGlyphs: glyphsByHouse[index],
})),
planets: block.planets.map((planet) => ({
name: language === "en" ? planet.name : PLANET_LABEL[planet.name],
retrograde: planet.retrograde,
})),
};
}
/**
* 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");
}