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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user