feat(chart): 星盘改用行星符号与颜色,度数进行星表
Independent Staging Quality Gate / validate (push) Failing after 8m58s
Independent Staging Quality Gate / publish (push) Skipped

北印星盘(星盘页 / 校正右栏 / 我的报告共用 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:
Jesse_Chen
2026-09-18 00:39:13 +00:00
co-authored by Claude Fable 5.1
parent 84b293fb47
commit 5a1dcbd2a1
20 changed files with 1239 additions and 73 deletions
@@ -5,7 +5,15 @@
*
* House geometry is the standard diamond chart: two diagonals plus the
* inner rhombus from the four midpoints. Sign numbers are rasi ordinals
* (1 = Aries … 12 = Pisces). Occupant labels are already display text.
* (1 = Aries … 12 = Pisces).
*
* Planets are drawn as coloured symbols, not as text. The old label form
* ("水 19°", "罗逆 29°") stacked three lines on top of each other as soon as a
* house held three bodies, and the font size had to shrink to fit. Degrees,
* nakshatra and direction now live in the planet table under the chart page's
* chart; the chart itself answers only "which body sits where".
*
* Colour identifies a body. It is not a benefic/malefic signal.
*/
import { useId } from "react";
@@ -15,15 +23,47 @@ import {
CHART_SIZE,
HOUSE_NUMBERS,
HOUSE_POLYGONS,
polygonCentroid,
occupantAnchor,
polygonPoints,
signNumberAnchor,
} from "@/lib/north-indian-chart-geometry";
import {
ASCENDANT_COLOR_CLASS,
ASCENDANT_MARK,
PLANET_COLOR_CLASS,
PLANET_GLYPH,
PLANET_GLYPH_ZH,
PLANET_LEGEND_ORDER,
parseChartOccupant,
planetGlyphKey,
type PlanetGlyphKey,
} from "@/lib/planet-glyphs";
import { signOrdinal, type NorthIndianChartModel } from "@/lib/report-chart-block";
export const CHART_VIEWBOX_WIDTH = CHART_SIZE;
export const CHART_VIEWBOX_HEIGHT = CHART_SIZE;
/** Extra viewBox rows under the square, used only when the legend is drawn. */
const LEGEND_BAND_HEIGHT = 52;
const LEGEND_ROW_ONE_Y = CHART_SIZE + 20;
const LEGEND_ROW_TWO_Y = CHART_SIZE + 42;
const LEGEND_COLUMN_WIDTH = 48;
const LEGEND_LABEL_OFFSET = 17;
// Two rows: nine symbol/name pairs plus the retrograde note do not fit on one
// 400-unit line at legend size without crowding.
const LEGEND_ROW_ONE = PLANET_LEGEND_ORDER.slice(0, 5);
const LEGEND_ROW_TWO = PLANET_LEGEND_ORDER.slice(5);
const LEGEND_RETROGRADE_COPY = "横线=逆行";
/** Symbol grid inside one house, in viewBox units of the 400-unit square. */
const GLYPHS_PER_ROW = 3;
const GLYPH_COLUMN_STEP = 32;
const GLYPH_ROW_STEP = 29;
const RETROGRADE_BAR_HALF_WIDTH = 9;
const RETROGRADE_BAR_DROP = 13;
const MAX_RENDERED_OCCUPANTS = 6;
export type VedicChartModel = NorthIndianChartModel;
interface VedicChartSvgProps {
@@ -32,68 +72,223 @@ interface VedicChartSvgProps {
highlightHouseNumbers?: ReadonlySet<number>;
}
function buildRetrogradeSet(chart: NorthIndianChartModel): Set<string> {
const retrograde = new Set<string>();
type ChartToken =
| { kind: "planet"; key: PlanetGlyphKey; retrograde: boolean }
| { kind: "ascendant" }
| { kind: "text"; label: string }
| { kind: "overflow"; label: string };
/** Bodies the chart's own planet list marks retrograde, keyed by glyph. */
function retrogradeKeys(chart: NorthIndianChartModel): Set<PlanetGlyphKey> {
const retrograde = new Set<PlanetGlyphKey>();
for (const planet of chart.planets ?? []) {
if (planet.retrograde) retrograde.add(planet.name);
if (!planet.retrograde) continue;
const key = planetGlyphKey(planet.name);
if (key) retrograde.add(key);
}
return retrograde;
}
function occupantLines(house: NorthIndianChartModel["houses"][number], chart: NorthIndianChartModel): string[] {
const retrograde = buildRetrogradeSet(chart);
return house.occupants.map((name) => {
if (name.includes("逆") || name.includes("°")) return name;
return retrograde.has(name) ? `${name}逆` : name;
/**
* House occupants as drawable tokens.
*
* `occupantGlyphs` is the structured path: `toNorthIndianChart` knows the body
* and its direction before it ever formats a label, so the chart page and the
* markdown chart blocks never guess. The rectification board and the personal
* report hand over display text only — their house tables come from the engine
* and from golden-fixture-shaped contracts that may not be invented here — so
* those two routes are read back with `parseChartOccupant`.
*/
function houseTokens(
house: NorthIndianChartModel["houses"][number],
retrograde: ReadonlySet<PlanetGlyphKey>,
): ChartToken[] {
if (house.occupantGlyphs) {
return house.occupantGlyphs.map((occupant) => ({
kind: "planet" as const,
key: occupant.key,
retrograde: occupant.retrograde || retrograde.has(occupant.key),
}));
}
return house.occupants.map((label) => {
const occupant = parseChartOccupant(label);
if (occupant.kind === "planet") {
return {
kind: "planet" as const,
key: occupant.key,
retrograde: occupant.retrograde || retrograde.has(occupant.key),
};
}
if (occupant.kind === "ascendant") return { kind: "ascendant" as const };
return { kind: "text" as const, label: occupant.label };
});
}
const MAX_RENDERED_OCCUPANTS = 6;
function visibleOccupantLines(lines: string[]): string[] {
if (lines.length <= MAX_RENDERED_OCCUPANTS) return lines;
return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)}`];
/**
* House 1 of a North Indian chart is the ascendant by construction, so the mark
* is drawn from geometry rather than from data. An explicit ascendant occupant
* (the report bundle can carry one) collapses into the same mark.
*/
function withAscendantMark(houseNumber: number, tokens: ChartToken[]): ChartToken[] {
const rest = tokens.filter((token) => token.kind !== "ascendant");
return houseNumber === 1 ? [{ kind: "ascendant" }, ...rest] : rest;
}
function OccupantStack({
lines,
origin,
}: {
lines: string[];
origin: { x: number; y: number };
}) {
const visible = visibleOccupantLines(lines);
const fontSize = visible.length > 4 ? 8 : 9;
const rowStep = visible.length > 4 ? 10 : 12;
const startY = origin.y - ((visible.length - 1) * rowStep) / 2;
/**
* The overflow budget counts occupants, not the ascendant mark: "As" is drawn
* from the chart's geometry, and letting it push a real body into `+N` would
* make house 1 show one body fewer than every other house.
*/
function visibleTokens(tokens: ChartToken[]): ChartToken[] {
const ascendant = tokens.filter((token) => token.kind === "ascendant");
const occupants = tokens.filter((token) => token.kind !== "ascendant");
if (occupants.length <= MAX_RENDERED_OCCUPANTS) return [...ascendant, ...occupants];
return [
...ascendant,
...occupants.slice(0, MAX_RENDERED_OCCUPANTS - 1),
{ kind: "overflow", label: `+${occupants.length - (MAX_RENDERED_OCCUPANTS - 1)}` },
];
}
function tokenKey(token: ChartToken, index: number): string {
if (token.kind === "planet") return `${token.key}-${index}`;
if (token.kind === "ascendant") return `asc-${index}`;
return `${token.label}-${index}`;
}
function OccupantGlyph({ token, x, y }: { token: ChartToken; x: number; y: number }) {
if (token.kind === "ascendant") {
return (
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className={`personal-report-chart-asc ${ASCENDANT_COLOR_CLASS}`}
>
{ASCENDANT_MARK}
</text>
);
}
if (token.kind !== "planet") {
return (
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className={token.kind === "overflow"
? "personal-report-chart-overflow"
: "personal-report-chart-occupant-text"}
>
{token.label}
</text>
);
}
const color = PLANET_COLOR_CLASS[token.key];
return (
<>
{visible.map((line, index) => (
<text
key={`${line}-${index}`}
x={origin.x}
y={startY + index * rowStep}
textAnchor="middle"
fontSize={fontSize}
>
{line}
</text>
))}
<text
x={x}
y={y}
textAnchor="middle"
dominantBaseline="middle"
className={`personal-report-chart-glyph ${color}`}
>
{PLANET_GLYPH[token.key]}
</text>
{token.retrograde && (
<line
x1={x - RETROGRADE_BAR_HALF_WIDTH}
x2={x + RETROGRADE_BAR_HALF_WIDTH}
y1={y + RETROGRADE_BAR_DROP}
y2={y + RETROGRADE_BAR_DROP}
className={`personal-report-chart-retrograde ${color}`}
/>
)}
</>
);
}
/** Up to three symbols per row, centred; a fourth body starts a second row. */
function OccupantGrid({ tokens, origin }: { tokens: ChartToken[]; origin: { x: number; y: number } }) {
const visible = visibleTokens(tokens);
const rows = Math.ceil(visible.length / GLYPHS_PER_ROW);
return (
<>
{visible.map((token, index) => {
const column = index % GLYPHS_PER_ROW;
const row = Math.floor(index / GLYPHS_PER_ROW);
const columnsInRow = row === rows - 1
? visible.length - row * GLYPHS_PER_ROW
: GLYPHS_PER_ROW;
return (
<OccupantGlyph
key={tokenKey(token, index)}
token={token}
x={origin.x + (column - (columnsInRow - 1) / 2) * GLYPH_COLUMN_STEP}
y={origin.y + (row - (rows - 1) / 2) * GLYPH_ROW_STEP}
/>
);
})}
</>
);
}
/** Nine symbols with their one-character names, plus what the bar under one means. */
function ChartLegend() {
const row = (keys: readonly PlanetGlyphKey[], y: number) => keys.map((key, index) => (
<g key={key}>
<text
x={index * LEGEND_COLUMN_WIDTH}
y={y}
className={`personal-report-chart-legend-glyph ${PLANET_COLOR_CLASS[key]}`}
>
{PLANET_GLYPH[key]}
</text>
<text
x={index * LEGEND_COLUMN_WIDTH + LEGEND_LABEL_OFFSET}
y={y}
className="personal-report-chart-legend"
>
{PLANET_GLYPH_ZH[key]}
</text>
</g>
));
return (
<g>
{row(LEGEND_ROW_ONE, LEGEND_ROW_ONE_Y)}
{row(LEGEND_ROW_TWO, LEGEND_ROW_TWO_Y)}
<text
x={LEGEND_ROW_TWO.length * LEGEND_COLUMN_WIDTH}
y={LEGEND_ROW_TWO_Y}
className="personal-report-chart-legend"
>
{LEGEND_RETROGRADE_COPY}
</text>
</g>
);
}
export function VedicChartSvg({ chart, ariaLabel, highlightHouseNumbers }: VedicChartSvgProps) {
const chartId = useId().replace(/:/g, "");
const linesByHouse = new Map<number, string[]>();
const retrograde = retrogradeKeys(chart);
const tokensByHouse = new Map<number, ChartToken[]>();
for (const house of chart.houses) {
linesByHouse.set(house.houseNumber, occupantLines(house, chart));
tokensByHouse.set(house.houseNumber, houseTokens(house, retrograde));
}
const hasRetrogradeMarker = [...linesByHouse.values()].some((lines) => lines.some((line) => line.includes("逆")));
// An empty grid stays empty: the ascendant mark and the legend are only worth
// drawing once the chart actually carries bodies.
const hasPlanets = [...tokensByHouse.values()]
.some((tokens) => tokens.some((token) => token.kind === "planet"));
if (hasPlanets) {
tokensByHouse.set(1, withAscendantMark(1, tokensByHouse.get(1) ?? []));
}
const viewBoxHeight = CHART_SIZE + (hasPlanets ? LEGEND_BAND_HEIGHT : 0);
return (
<svg
viewBox={`0 0 ${CHART_VIEWBOX_WIDTH} ${CHART_VIEWBOX_HEIGHT}`}
viewBox={`0 0 ${CHART_VIEWBOX_WIDTH} ${viewBoxHeight}`}
width="100%"
height="auto"
role="img"
@@ -110,8 +305,7 @@ export function VedicChartSvg({ chart, ariaLabel, highlightHouseNumbers }: Vedic
{HOUSE_NUMBERS.map((houseNumber) => {
const polygon = HOUSE_POLYGONS[houseNumber];
const house = chart.houses.find((item) => item.houseNumber === houseNumber);
const lines = house ? linesByHouse.get(houseNumber) ?? [] : [];
const centroid = polygonCentroid(polygon);
const tokens = house ? tokensByHouse.get(houseNumber) ?? [] : [];
const numberAnchor = signNumberAnchor(houseNumber);
const ordinal = house ? signOrdinal(house.sign) : null;
return (
@@ -133,22 +327,12 @@ export function VedicChartSvg({ chart, ariaLabel, highlightHouseNumbers }: Vedic
</text>
)}
<g clipPath={`url(#${chartId}-house-${houseNumber})`}>
<OccupantStack lines={lines} origin={centroid} />
<OccupantGrid tokens={tokens} origin={occupantAnchor(houseNumber)} />
</g>
</g>
);
})}
{hasRetrogradeMarker && (
<text
x={CHART_VIEWBOX_WIDTH - 6}
y={CHART_VIEWBOX_HEIGHT - 8}
textAnchor="end"
fontSize={9}
className="personal-report-chart-muted"
>
“逆”=逆行
</text>
)}
{hasPlanets && <ChartLegend />}
</svg>
);
}