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
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
84b293fb47
commit
5a1dcbd2a1
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PLANET_GLYPH_KEYS } from "./planet-glyphs.ts";
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
export const chartViewUnavailableLayerSchema = z.object({
|
||||
@@ -13,6 +15,14 @@ export const northIndianChartSchema = z.object({
|
||||
houseNumber: z.number().int().min(1).max(12),
|
||||
sign: z.string().min(1),
|
||||
occupants: z.array(z.string()),
|
||||
// The chart draws symbols, so it needs the body and its direction rather
|
||||
// than the formatted label. Optional because the schema also has to accept
|
||||
// house tables that only ever carried display text.
|
||||
occupantGlyphs: z.array(z.object({
|
||||
key: z.enum(PLANET_GLYPH_KEYS),
|
||||
retrograde: z.boolean(),
|
||||
degree: z.number().gte(0).lt(30),
|
||||
})).optional(),
|
||||
})).length(12),
|
||||
planets: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
@@ -37,6 +47,24 @@ export const chartViewPlanetRowSchema = z.object({
|
||||
symbol: z.string().nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The ascendant is a chart point, not a body: the engine returns its sign and
|
||||
* degree but no nakshatra, so those three fields are nullable rather than
|
||||
* filled in with a plausible value.
|
||||
*/
|
||||
export const chartViewAscendantRowSchema = z.object({
|
||||
label: z.string().min(1),
|
||||
abbr: z.string().min(1),
|
||||
sign: z.string().min(1),
|
||||
signLabel: z.string().min(1),
|
||||
degreeInSign: z.number(),
|
||||
house: z.literal(1),
|
||||
retrograde: z.literal(false),
|
||||
nakshatra: z.string().min(1).nullable(),
|
||||
pada: z.number().int().min(1).max(4).nullable(),
|
||||
nakshatraLord: z.string().min(1).nullable(),
|
||||
});
|
||||
|
||||
export const chartViewVargaSchema = z.object({
|
||||
id: z.string().regex(/^D\d{1,3}$/),
|
||||
title: z.string().min(1),
|
||||
@@ -164,6 +192,7 @@ export const chartViewOkSchema = z.object({
|
||||
coordinateSystem: z.literal("sidereal"),
|
||||
boundary: z.string().min(1),
|
||||
vargas: z.array(chartViewVargaSchema).min(1),
|
||||
ascendant: chartViewAscendantRowSchema.nullable(),
|
||||
planets: z.array(chartViewPlanetRowSchema).min(1),
|
||||
}),
|
||||
dasha: z.object({
|
||||
@@ -192,6 +221,7 @@ export type ChartViewQizhengOk = z.infer<typeof chartViewQizhengOkSchema>;
|
||||
export type ChartViewVarga = z.infer<typeof chartViewVargaSchema>;
|
||||
export type ChartViewPlanetRow = z.infer<typeof chartViewPlanetRowSchema>;
|
||||
export type NorthIndianChart = z.infer<typeof northIndianChartSchema>;
|
||||
export type ChartViewAscendantRow = z.infer<typeof chartViewAscendantRowSchema>;
|
||||
|
||||
export const CHART_VIEW_TABS = [
|
||||
{ id: "vedic", label: "星盘" },
|
||||
|
||||
@@ -300,6 +300,32 @@ 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.
|
||||
*/
|
||||
function ascendantRow(chart: Record<string, unknown>) {
|
||||
const ascendant = record(chart.ascendant);
|
||||
const sign = text(ascendant?.sign);
|
||||
if (!ascendant || !sign) return null;
|
||||
const nakshatra = text(ascendant.nakshatra);
|
||||
const pada = finite(ascendant.nakshatra_pada);
|
||||
const lord = text(ascendant.nakshatra_lord);
|
||||
return {
|
||||
label: "上升",
|
||||
abbr: "As",
|
||||
sign,
|
||||
signLabel: signZh(sign),
|
||||
degreeInSign: planetDegreeInSign(ascendant),
|
||||
house: 1 as const,
|
||||
retrograde: false as const,
|
||||
nakshatra,
|
||||
pada: pada === null ? null : Math.min(4, Math.max(1, Math.trunc(pada))),
|
||||
nakshatraLord: lord ? planetZh(lord) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function vimshottariTrack(chart: Record<string, unknown>, asOf: string) {
|
||||
const dasha = record(chart.dasha) ?? {};
|
||||
const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : [];
|
||||
@@ -651,6 +677,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
coordinateSystem: "sidereal" as const,
|
||||
boundary: COORDINATE_BOUNDARY.vedic,
|
||||
vargas,
|
||||
ascendant: ascendantRow(chart),
|
||||
planets: planetRows(chart, varga),
|
||||
},
|
||||
dasha: {
|
||||
|
||||
@@ -104,6 +104,24 @@ function innerVertex(points: readonly Point[]): Point {
|
||||
return points.reduce((best, point) => (distance(point, CENTER) < distance(best, CENTER) ? point : best));
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a house's planet symbols sit. The raw centroid crowds the sign number
|
||||
* in the triangular houses, so the group is nudged away from the vertex the
|
||||
* number hangs on — the centre for the four diamonds, the right angle for the
|
||||
* eight triangles.
|
||||
*/
|
||||
export function occupantAnchor(houseNumber: number): Point {
|
||||
const polygon = HOUSE_POLYGONS[houseNumber];
|
||||
const centroid = polygonCentroid(polygon);
|
||||
const isDiamond = polygon.length === 4;
|
||||
const anchorVertex = isDiamond ? innerVertex(polygon) : rightAngleVertex(polygon);
|
||||
const push = isDiamond ? 0.05 : 0.18;
|
||||
return {
|
||||
x: centroid.x + (centroid.x - anchorVertex.x) * push,
|
||||
y: centroid.y + (centroid.y - anchorVertex.y) * push,
|
||||
};
|
||||
}
|
||||
|
||||
export function signNumberAnchor(houseNumber: number): Point {
|
||||
const polygon = HOUSE_POLYGONS[houseNumber];
|
||||
const centroid = polygonCentroid(polygon);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Planet symbols for the North Indian chart and the chart page planet table.
|
||||
*
|
||||
* The chart used to print display text inside each house ("水 19°", "罗逆 29°"),
|
||||
* which stacked three labels on top of each other whenever a house held three
|
||||
* planets. The chart now carries symbols only; degrees, nakshatra and direction
|
||||
* live in the planet table under the chart.
|
||||
*
|
||||
* Colour is an identification aid, not a verdict: nothing here says benefic or
|
||||
* malefic. Rahu and Ketu deliberately share one neutral grey.
|
||||
*
|
||||
* ♀ and ♂ are followed by U+FE0E (VARIATION SELECTOR-15, text presentation).
|
||||
* Without it iOS renders both as colour emoji.
|
||||
*/
|
||||
|
||||
export const PLANET_GLYPH_KEYS = [
|
||||
"sun",
|
||||
"moon",
|
||||
"mars",
|
||||
"mercury",
|
||||
"jupiter",
|
||||
"venus",
|
||||
"saturn",
|
||||
"rahu",
|
||||
"ketu",
|
||||
] as const;
|
||||
|
||||
export type PlanetGlyphKey = (typeof PLANET_GLYPH_KEYS)[number];
|
||||
|
||||
const TEXT_PRESENTATION = "\uFE0E";
|
||||
|
||||
export const PLANET_GLYPH: Readonly<Record<PlanetGlyphKey, string>> = {
|
||||
sun: "☉",
|
||||
moon: "☽",
|
||||
mars: `♂${TEXT_PRESENTATION}`,
|
||||
mercury: "☿",
|
||||
jupiter: "♃",
|
||||
venus: `♀${TEXT_PRESENTATION}`,
|
||||
saturn: "♄",
|
||||
rahu: "☊",
|
||||
ketu: "☋",
|
||||
};
|
||||
|
||||
/** One-character Chinese name, as used in the chart legend. */
|
||||
export const PLANET_GLYPH_ZH: Readonly<Record<PlanetGlyphKey, string>> = {
|
||||
sun: "日",
|
||||
moon: "月",
|
||||
mars: "火",
|
||||
mercury: "水",
|
||||
jupiter: "木",
|
||||
venus: "金",
|
||||
saturn: "土",
|
||||
rahu: "罗",
|
||||
ketu: "计",
|
||||
};
|
||||
|
||||
/** Full Chinese name, as used in the planet table. */
|
||||
export const PLANET_NAME_ZH: Readonly<Record<PlanetGlyphKey, string>> = {
|
||||
sun: "太阳",
|
||||
moon: "月亮",
|
||||
mars: "火星",
|
||||
mercury: "水星",
|
||||
jupiter: "木星",
|
||||
venus: "金星",
|
||||
saturn: "土星",
|
||||
rahu: "北交点",
|
||||
ketu: "南交点",
|
||||
};
|
||||
|
||||
export const PLANET_ABBR: Readonly<Record<PlanetGlyphKey, string>> = {
|
||||
sun: "Su",
|
||||
moon: "Mo",
|
||||
mars: "Ma",
|
||||
mercury: "Me",
|
||||
jupiter: "Ju",
|
||||
venus: "Ve",
|
||||
saturn: "Sa",
|
||||
rahu: "Ra",
|
||||
ketu: "Ke",
|
||||
};
|
||||
|
||||
/**
|
||||
* Colour modifier per body; both nodes share one. The colours themselves are
|
||||
* `--color-planet-*` in globals.css, one value per theme.
|
||||
*
|
||||
* A class rather than an inline `style`: the personal report's markup contract
|
||||
* forbids a `style=` attribute anywhere in the rendered document, and that rule
|
||||
* is load-bearing (it is what keeps agent-authored content from carrying its
|
||||
* own CSS), so the chart may not be the one exception.
|
||||
*/
|
||||
export const PLANET_COLOR_CLASS: Readonly<Record<PlanetGlyphKey, string>> = {
|
||||
sun: "is-sun",
|
||||
moon: "is-moon",
|
||||
mars: "is-mars",
|
||||
mercury: "is-mercury",
|
||||
jupiter: "is-jupiter",
|
||||
venus: "is-venus",
|
||||
saturn: "is-saturn",
|
||||
rahu: "is-node",
|
||||
ketu: "is-node",
|
||||
};
|
||||
|
||||
export const ASCENDANT_COLOR_CLASS = "is-ascendant";
|
||||
/** The North Indian first house is always the ascendant, so the mark needs no data. */
|
||||
export const ASCENDANT_MARK = "As";
|
||||
export const ASCENDANT_NAME_ZH = "上升";
|
||||
|
||||
/** Chinese names the engines emit, keyed by their first character. */
|
||||
const ZH_FIRST_CHARACTER: Readonly<Record<string, PlanetGlyphKey>> = {
|
||||
日: "sun",
|
||||
太: "sun", // 太阳
|
||||
月: "moon",
|
||||
火: "mars",
|
||||
水: "mercury",
|
||||
木: "jupiter",
|
||||
金: "venus",
|
||||
土: "saturn",
|
||||
罗: "rahu", // 罗睺
|
||||
计: "ketu", // 计都
|
||||
};
|
||||
|
||||
const EN_NAMES: Readonly<Record<string, PlanetGlyphKey>> = {
|
||||
sun: "sun",
|
||||
moon: "moon",
|
||||
mars: "mars",
|
||||
mercury: "mercury",
|
||||
jupiter: "jupiter",
|
||||
venus: "venus",
|
||||
saturn: "saturn",
|
||||
rahu: "rahu",
|
||||
ketu: "ketu",
|
||||
};
|
||||
|
||||
const ASCENDANT_NAMES = new Set(["as", "asc", "ascendant", "lagna"]);
|
||||
/** 上升 / 上升点 / 上升星座 — the report's own wording for the same point. */
|
||||
const ASCENDANT_ZH_PREFIX = "上升";
|
||||
|
||||
/**
|
||||
* Maps a label to a glyph key. Accepts every spelling the three call sites can
|
||||
* produce: the report chart block's one-character names ("水", "罗"), the
|
||||
* rectification house table's full Chinese names ("水星", "罗睺"), and the
|
||||
* report evidence bundle's English celestial names ("Mercury", "Rahu").
|
||||
*/
|
||||
export function planetGlyphKey(name: string): PlanetGlyphKey | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
const latin = trimmed.match(/^[A-Za-z]+/);
|
||||
if (latin) return EN_NAMES[latin[0].toLowerCase()] ?? null;
|
||||
return ZH_FIRST_CHARACTER[trimmed[0]!] ?? null;
|
||||
}
|
||||
|
||||
export type ChartOccupant =
|
||||
| { kind: "planet"; key: PlanetGlyphKey; retrograde: boolean; label: string }
|
||||
| { kind: "ascendant"; label: string }
|
||||
| { kind: "text"; label: string };
|
||||
|
||||
/**
|
||||
* Reads one occupant string. The rectification board and the personal report
|
||||
* both receive their house tables as engine/agent display text and have no
|
||||
* structured planet list to join against — their contracts are golden-fixture
|
||||
* shaped and may not be invented here — so those two routes are parsed.
|
||||
* `toNorthIndianChart` instead attaches `occupantGlyphs` and never reaches this.
|
||||
*/
|
||||
export function parseChartOccupant(label: string): ChartOccupant {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return { kind: "text", label };
|
||||
if (ASCENDANT_NAMES.has(trimmed.toLowerCase()) || trimmed.startsWith(ASCENDANT_ZH_PREFIX)) {
|
||||
return { kind: "ascendant", label: trimmed };
|
||||
}
|
||||
const key = planetGlyphKey(trimmed);
|
||||
if (!key) return { kind: "text", label: trimmed };
|
||||
return { kind: "planet", key, retrograde: trimmed.includes("逆"), label: trimmed };
|
||||
}
|
||||
|
||||
/** Reading order for the chart legend: 日 月 水 金 火 木 土 罗 计. */
|
||||
export const PLANET_LEGEND_ORDER = [
|
||||
"sun",
|
||||
"moon",
|
||||
"mercury",
|
||||
"venus",
|
||||
"mars",
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"rahu",
|
||||
"ketu",
|
||||
] as const satisfies readonly PlanetGlyphKey[];
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { PLANET_GLYPH_KEYS, type PlanetGlyphKey } from "./planet-glyphs";
|
||||
|
||||
const SIGNS = [
|
||||
"Aries",
|
||||
"Taurus",
|
||||
@@ -99,8 +101,21 @@ export const reportChartBlockSchema = z.strictObject({
|
||||
|
||||
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[] }[];
|
||||
houses: {
|
||||
houseNumber: number;
|
||||
sign: string;
|
||||
occupants: string[];
|
||||
occupantGlyphs?: { key: PlanetGlyphKey; retrograde: boolean; degree: number }[];
|
||||
}[];
|
||||
planets?: { name: string; retrograde: boolean }[];
|
||||
};
|
||||
|
||||
@@ -137,21 +152,45 @@ 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],
|
||||
|
||||
Reference in New Issue
Block a user