Files
Jyotisha/frontend/src/lib/report-chart-block.ts
T
Jesse_ChenandClaude Fable 5.1 5a1dcbd2a1
Independent Staging Quality Gate / validate (push) Failing after 8m58s
Independent Staging Quality Gate / publish (push) Skipped
feat(chart): 星盘改用行星符号与颜色,度数进行星表
北印星盘(星盘页 / 校正右栏 / 我的报告共用 VedicChartSvg)宫内不再画
「水 19°」「罗逆 29°」这类显示文本——一宫三颗星就叠三行、字号压到 8。
改成九个彩色符号横排,每行最多 3 个;逆行是符号下方一道同色横线,
第 1 宫加 As 小标,盘下方两行图例。度数、星宿、顺逆下沉到星盘页的
行星表,列从四列扩到八列,首行是上升,度数精确到分。

- 符号字体:vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB),
  经 next/font/local 挂成 --font-planet-glyphs;♀ ♂ 带 U+FE0E 防 iOS
  画成表情。构建镜像不能联网,所以必须自带,与 Inter 同一条理由。
- 颜色:--color-planet-* 九个 token,:root 与两个深色块各一份。只用于
  认星不表吉凶,罗计共用中性灰。挂 is-* 修饰类而非行内 style——报告
  文档的标记合同禁止渲染结果出现 style=,星盘不做例外。
- 数据来源:toNorthIndianChart() 加结构化 occupantGlyphs(星盘页与
  markdown fence 走这条);校正与报告两条路线的模型只有引擎 / golden
  显示文本,按首字解析,解析由测试锁住。
- 星宿沿用引擎返回名:仓库无中文宿名表,印度 27 宿与中国二十八宿不是
  同一套,不自造对照。

tsc 0 错,lint 0 error,npm test 3471→3484 条、36 红与基线逐条相同,
/ 仍 Static,首屏 JS gzip +0.29%。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-18 00:39:13 +00:00

206 lines
5.0 KiB
TypeScript

import { z } from "zod";
import { PLANET_GLYPH_KEYS, type PlanetGlyphKey } from "./planet-glyphs";
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>;
/**
* `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,
): 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} 北印度盘`;
}
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): 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),
);
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: 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");
}