Files
Jyotisha/frontend/src/lib/report-chart-block.ts
T
Jesse_ChenandCursor 2fdcb14fd6 fix(report): draw North Indian charts from fences after skipHtml dropped SVG
Longform report pages kept the D1/D9/Moon and varga headings but skipHtml
stripped the engine's inline SVG. Emit a jyotish-chart JSON fence beside
each SVG and render a diamond chart on the reader without relaxing HTML
sanitization. BUG-607.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 15:11:00 +08:00

167 lines
3.6 KiB
TypeScript

import { z } from "zod";
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 = /(?:\n[ \t]*)?```jyotish-chart\n[\s\S]*?```(?:\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>;
export type NorthIndianChartModel = {
houses: { houseNumber: number; sign: string; occupants: string[] }[];
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,
): string {
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"]): string {
return id === "MOON" ? "月亮参考盘" : `${id} 北印度盘`;
}
export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartModel {
const ascIndex = SIGNS.indexOf(block.ascendant.sign);
const occupantsByHouse: string[][] = 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),
);
}
return {
houses: Array.from({ length: 12 }, (_, index) => ({
houseNumber: index + 1,
sign: SIGNS[(ascIndex + index + 12) % 12],
occupants: occupantsByHouse[index],
})),
planets: block.planets.map((planet) => ({
name: PLANET_LABEL[planet.name],
retrograde: planet.retrograde,
})),
};
}
export function stripReportChartBlocks(markdown: string): string {
const stripped = markdown.replace(FENCE_RE, "\n\n");
return stripped.replace(/\n{3,}/g, "\n\n");
}