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
Binary file not shown.
@@ -3,3 +3,14 @@ File: InterVariable-latin.woff2
|
||||
Taken from @fontsource-variable/inter@5.3.0 files/inter-latin-wght-normal.woff2
|
||||
Upstream: https://github.com/rsms/inter
|
||||
License: SIL Open Font License 1.1 (OFL.txt)
|
||||
|
||||
Noto Sans Symbols — 9-glyph planet subset
|
||||
File: NotoSansSymbols-planets.woff2
|
||||
Taken from https://fonts.googleapis.com/css2?family=Noto+Sans+Symbols&text=☉☽☿♀♂♃♄☊☋
|
||||
(the CSS that URL returns points at one fonts.gstatic.com subset file; that file is this one)
|
||||
Glyphs: U+2609 ☉ 日 · U+263D ☽ 月 · U+263F ☿ 水 · U+2640 ♀ 金 · U+2642 ♂ 火 ·
|
||||
U+2643 ♃ 木 · U+2644 ♄ 土 · U+260A ☊ 罗 · U+260B ☋ 计
|
||||
Upstream: https://github.com/notofonts/symbols
|
||||
License: SIL Open Font License 1.1 (OFL.txt), © the Noto Project Authors
|
||||
Why vendored: the publish image has no network at build time, so next/font/google
|
||||
cannot be used. Same reason as Inter above.
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
--color-dark-hover: var(--color-dark-hover);
|
||||
--color-dark-muted: var(--color-dark-muted);
|
||||
--color-dark-border: var(--color-dark-border);
|
||||
--color-planet-sun: var(--color-planet-sun);
|
||||
--color-planet-moon: var(--color-planet-moon);
|
||||
--color-planet-mars: var(--color-planet-mars);
|
||||
--color-planet-mercury: var(--color-planet-mercury);
|
||||
--color-planet-jupiter: var(--color-planet-jupiter);
|
||||
--color-planet-venus: var(--color-planet-venus);
|
||||
--color-planet-saturn: var(--color-planet-saturn);
|
||||
--color-planet-node: var(--color-planet-node);
|
||||
--color-planet-ascendant: var(--color-planet-ascendant);
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
@@ -103,6 +112,19 @@
|
||||
--color-dark-hover: rgba(251, 250, 247, .1);
|
||||
--color-dark-muted: #aaa8a2;
|
||||
--color-dark-border: rgba(251, 250, 247, .16);
|
||||
/* Planet identity colours for the North Indian chart and the chart page's
|
||||
planet table. They answer "which body is this", nothing else — no benefic
|
||||
or malefic reading is encoded here, which is why Rahu and Ketu share one
|
||||
neutral grey. Both nodes and the ascendant are chart points, not bodies. */
|
||||
--color-planet-sun: #c94f4f;
|
||||
--color-planet-moon: #4c7fd1;
|
||||
--color-planet-mars: #d2553c;
|
||||
--color-planet-mercury: #3f9a6b;
|
||||
--color-planet-jupiter: #c9902e;
|
||||
--color-planet-venus: #c9689b;
|
||||
--color-planet-saturn: #5b6bb0;
|
||||
--color-planet-node: #6b7280;
|
||||
--color-planet-ascendant: #b0413e;
|
||||
--shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2);
|
||||
--shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18);
|
||||
/* Display and body share one sans stack. The old value led with two Anthropic
|
||||
@@ -241,6 +263,18 @@
|
||||
/* Hairlines that were dark-on-light have to become light-on-dark. */
|
||||
--ring-hairline: oklch(1 0 0 / .14);
|
||||
--sheen: rgba(255, 255, 255, .16);
|
||||
|
||||
/* Planet identity colours, lifted for a dark ground. Same roles, same shared
|
||||
node grey; the hues are raised rather than inverted. */
|
||||
--color-planet-sun: #e07a7a;
|
||||
--color-planet-moon: #7fa6e6;
|
||||
--color-planet-mars: #e6866f;
|
||||
--color-planet-mercury: #6cc39a;
|
||||
--color-planet-jupiter: #e0b45a;
|
||||
--color-planet-venus: #e08fbb;
|
||||
--color-planet-saturn: #8d9be0;
|
||||
--color-planet-node: #a1a7b3;
|
||||
--color-planet-ascendant: #e07b78;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +347,18 @@
|
||||
/* Hairlines that were dark-on-light have to become light-on-dark. */
|
||||
--ring-hairline: oklch(1 0 0 / .14);
|
||||
--sheen: rgba(255, 255, 255, .16);
|
||||
|
||||
/* Planet identity colours, lifted for a dark ground. Same roles, same shared
|
||||
node grey; the hues are raised rather than inverted. */
|
||||
--color-planet-sun: #e07a7a;
|
||||
--color-planet-moon: #7fa6e6;
|
||||
--color-planet-mars: #e6866f;
|
||||
--color-planet-mercury: #6cc39a;
|
||||
--color-planet-jupiter: #e0b45a;
|
||||
--color-planet-venus: #e08fbb;
|
||||
--color-planet-saturn: #8d9be0;
|
||||
--color-planet-node: #a1a7b3;
|
||||
--color-planet-ascendant: #e07b78;
|
||||
}
|
||||
|
||||
|
||||
@@ -4147,6 +4193,63 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.personal-report-chart-svg .personal-report-chart-diamond { fill: none; stroke: var(--color-border-strong); stroke-width: 1.25; }
|
||||
.personal-report-chart-svg text { fill: currentColor; }
|
||||
.personal-report-chart-svg .personal-report-chart-muted { fill: var(--color-ink-muted, var(--color-ink-tertiary)); }
|
||||
/* Planets are symbols, not labels. Sizes are viewBox units of the 400-unit
|
||||
square: at the ~318px the chart page gives the stage, 22 units lands near
|
||||
17px, which is why the old blanket `text { font-size: 16px }` override had to
|
||||
become per-class — it would have shrunk the symbols instead of lifting the
|
||||
sign numbers. The stack leads with the vendored 9-glyph Noto subset so the
|
||||
set renders identically on Android and Linux, where no system face covers it.
|
||||
U+FE0E after ♀ and ♂ is what keeps iOS from drawing them as emoji, and it is
|
||||
applied at the string, not here. */
|
||||
.personal-report-chart-svg .personal-report-chart-glyph,
|
||||
.personal-report-chart-svg .personal-report-chart-legend-glyph {
|
||||
font-family: var(--font-planet-glyphs), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
.personal-report-chart-svg .personal-report-chart-glyph { font-size: 22px; }
|
||||
.personal-report-chart-svg .personal-report-chart-legend-glyph { font-size: 14px; }
|
||||
/* Retrograde is a bar under the symbol in the symbol's own colour; the chart no
|
||||
longer writes the character 逆 anywhere. */
|
||||
.personal-report-chart-svg .personal-report-chart-retrograde { stroke-width: 1.9; }
|
||||
/* House 1 of a North Indian chart is the ascendant by construction. */
|
||||
.personal-report-chart-svg .personal-report-chart-asc {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.02em;
|
||||
}
|
||||
.personal-report-chart-svg .personal-report-chart-overflow {
|
||||
font-size: 13px;
|
||||
fill: var(--color-ink-tertiary);
|
||||
}
|
||||
/* Only reached by an occupant string that names no known body — the label is
|
||||
drawn as written rather than dropped. */
|
||||
.personal-report-chart-svg .personal-report-chart-occupant-text { font-size: 12px; }
|
||||
.personal-report-chart-svg .personal-report-chart-legend {
|
||||
font-size: 12px;
|
||||
fill: var(--color-ink-tertiary);
|
||||
}
|
||||
/* One colour per body, carried by a modifier class rather than an inline
|
||||
`style` — the personal report's markup contract forbids a style attribute
|
||||
anywhere in the rendered document, and the chart is not an exception to it.
|
||||
`text` takes fill; the retrograde bar takes stroke. Painting both on one
|
||||
selector would outline the symbols. */
|
||||
.personal-report-chart-svg text.is-sun { fill: var(--color-planet-sun); }
|
||||
.personal-report-chart-svg line.is-sun { stroke: var(--color-planet-sun); }
|
||||
.personal-report-chart-svg text.is-moon { fill: var(--color-planet-moon); }
|
||||
.personal-report-chart-svg line.is-moon { stroke: var(--color-planet-moon); }
|
||||
.personal-report-chart-svg text.is-mars { fill: var(--color-planet-mars); }
|
||||
.personal-report-chart-svg line.is-mars { stroke: var(--color-planet-mars); }
|
||||
.personal-report-chart-svg text.is-mercury { fill: var(--color-planet-mercury); }
|
||||
.personal-report-chart-svg line.is-mercury { stroke: var(--color-planet-mercury); }
|
||||
.personal-report-chart-svg text.is-jupiter { fill: var(--color-planet-jupiter); }
|
||||
.personal-report-chart-svg line.is-jupiter { stroke: var(--color-planet-jupiter); }
|
||||
.personal-report-chart-svg text.is-venus { fill: var(--color-planet-venus); }
|
||||
.personal-report-chart-svg line.is-venus { stroke: var(--color-planet-venus); }
|
||||
.personal-report-chart-svg text.is-saturn { fill: var(--color-planet-saturn); }
|
||||
.personal-report-chart-svg line.is-saturn { stroke: var(--color-planet-saturn); }
|
||||
.personal-report-chart-svg text.is-node { fill: var(--color-planet-node); }
|
||||
.personal-report-chart-svg line.is-node { stroke: var(--color-planet-node); }
|
||||
.personal-report-chart-svg text.is-ascendant { fill: var(--color-planet-ascendant); }
|
||||
.personal-report-chart-figure {
|
||||
min-width: 0;
|
||||
max-width: 360px;
|
||||
@@ -4643,7 +4746,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
padding: var(--space-3);
|
||||
}
|
||||
.chart-page-vedic-stage .personal-report-chart-svg { margin-top: 0; }
|
||||
.chart-page-vedic-stage .personal-report-chart-svg text { font-size: 16px; }
|
||||
/* Only the small type is lifted for the chart page's narrow stage. Raising
|
||||
every `text` would also resize the planet symbols, which are already sized
|
||||
for this width. */
|
||||
.chart-page-vedic-stage .personal-report-chart-svg .personal-report-chart-muted { font-size: 16px; }
|
||||
.chart-page-vedic-stage .personal-report-chart-svg .personal-report-chart-asc { font-size: 15px; }
|
||||
.chart-page-vedic-stage .personal-report-chart-svg .personal-report-chart-legend { font-size: 14px; }
|
||||
.chart-page-vedic-stage .personal-report-chart-svg .personal-report-chart-legend-glyph { font-size: 16px; }
|
||||
/* One key/value table replaces the `.chart-page-center-card` overlay (two lines
|
||||
on mobile, six on desktop, sat on the chart's geometric centre) and the
|
||||
`.chart-page-params-below` card that repeated the same six lines for mobile.
|
||||
@@ -4689,7 +4798,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
}
|
||||
.chart-page-planet-table {
|
||||
width: 100%;
|
||||
min-width: 26rem;
|
||||
/* Eight columns now (symbol+name, longitude, speed, house, nakshatra, its
|
||||
lord, pada, dignity); 26rem was sized for four and crushed the last three. */
|
||||
min-width: 34rem;
|
||||
border-collapse: collapse;
|
||||
background: var(--color-canvas);
|
||||
font-size: var(--type-body-sm);
|
||||
@@ -4713,6 +4824,28 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
font-size: var(--type-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
.chart-page-planet-table th,
|
||||
.chart-page-planet-table td { white-space: nowrap; }
|
||||
/* The symbol column repeats the chart's colour so a row and a house read as the
|
||||
same body; the stack matches the chart's for the same coverage reason. */
|
||||
.chart-page-planet-glyph {
|
||||
font-family: var(--font-planet-glyphs), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", serif;
|
||||
font-size: var(--type-title-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
.chart-page-planet-abbr { color: var(--color-ink-tertiary); }
|
||||
.chart-page-planet-glyph.is-sun { color: var(--color-planet-sun); }
|
||||
.chart-page-planet-glyph.is-moon { color: var(--color-planet-moon); }
|
||||
.chart-page-planet-glyph.is-mars { color: var(--color-planet-mars); }
|
||||
.chart-page-planet-glyph.is-mercury { color: var(--color-planet-mercury); }
|
||||
.chart-page-planet-glyph.is-jupiter { color: var(--color-planet-jupiter); }
|
||||
.chart-page-planet-glyph.is-venus { color: var(--color-planet-venus); }
|
||||
.chart-page-planet-glyph.is-saturn { color: var(--color-planet-saturn); }
|
||||
.chart-page-planet-glyph.is-node { color: var(--color-planet-node); }
|
||||
.chart-page-planet-glyph.is-ascendant { color: var(--color-planet-ascendant); font-weight: 700; }
|
||||
.chart-page-planet-table td.chart-page-planet-mid { text-align: center; }
|
||||
/* Figures line up column-wise instead of drifting with glyph width. */
|
||||
.chart-page-planet-num { font-variant-numeric: tabular-nums; }
|
||||
.chart-page-boundary {
|
||||
margin: var(--space-4) 0 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
|
||||
@@ -15,6 +15,22 @@ const inter = localFont({
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
// Vendored 9-glyph Noto Sans Symbols subset (OFL) — ☉ ☽ ☿ ♀ ♂ ♃ ♄ ☊ ☋, 1.5 KB.
|
||||
// The North Indian chart draws planets as these symbols, so the face has to be
|
||||
// present rather than hoped for: Apple Symbols / Segoe UI Symbol cover the set
|
||||
// on their own platforms, Android and most Linux browsers do not.
|
||||
// `adjustFontFallback: false` on purpose: the synthetic Arial-metric fallback
|
||||
// next/font/local would otherwise inject carries its own ♀ ♂ and would swallow
|
||||
// the rest of the stack for the glyphs it cannot draw.
|
||||
const planetGlyphs = localFont({
|
||||
src: "./fonts/NotoSansSymbols-planets.woff2",
|
||||
display: "swap",
|
||||
variable: "--font-planet-glyphs",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
adjustFontFallback: false,
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Jyotisha · 印度占星",
|
||||
description: "与 Mastra Agent 对话,基于星盘证据讨论事业、关系与时间窗口。",
|
||||
@@ -32,7 +48,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||
&& process.env.NEXT_PUBLIC_ENABLE_REACT_DEVTOOLS === "1";
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" className={inter.variable} suppressHydrationWarning>
|
||||
<html lang="zh-CN" className={`${inter.variable} ${planetGlyphs.variable}`} suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Synchronous on purpose: a pinned theme must be on <html> before the
|
||||
first paint, or the page flashes the other theme on every load. */}
|
||||
|
||||
@@ -7,3 +7,17 @@ export function formatCoord(latitude: number, longitude: number): string {
|
||||
export function formatDegree(degree: number): string {
|
||||
return `${Math.min(29.9, Math.max(0, degree)).toFixed(1)}°`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Degrees and arc-minutes inside the sign, e.g. `15°02′`.
|
||||
*
|
||||
* The planet table is the only place the chart page still states a position, so
|
||||
* it does not round to whole degrees the way the chart labels used to: 29°59′
|
||||
* and 0°01′ are a sign apart and must not both read as "30°".
|
||||
*/
|
||||
export function formatDegreeMinutes(degree: number): string {
|
||||
const clamped = Math.min(29.9999, Math.max(0, degree));
|
||||
const whole = Math.floor(clamped);
|
||||
const minutes = Math.min(59, Math.floor((clamped - whole) * 60));
|
||||
return `${whole}°${String(minutes).padStart(2, "0")}′`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
import type { ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import type {
|
||||
ChartViewAscendantRow,
|
||||
ChartViewOk,
|
||||
ChartViewPlanetRow,
|
||||
} from "@/lib/chart-view-contract";
|
||||
import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels";
|
||||
import { formatDegree } from "./chart-format";
|
||||
import {
|
||||
ASCENDANT_COLOR_CLASS,
|
||||
ASCENDANT_MARK,
|
||||
PLANET_ABBR,
|
||||
PLANET_COLOR_CLASS,
|
||||
PLANET_GLYPH,
|
||||
planetGlyphKey,
|
||||
} from "@/lib/planet-glyphs";
|
||||
import { formatDegreeMinutes } from "./chart-format";
|
||||
import { ChartParamTable } from "./chart-param-table";
|
||||
|
||||
/** The same coloured symbol the chart draws, so a row and a house read as one body. */
|
||||
function BodyGlyph({ name }: { name: string }) {
|
||||
const key = planetGlyphKey(name);
|
||||
if (!key) return <span className="chart-page-planet-glyph">—</span>;
|
||||
return (
|
||||
<span className={`chart-page-planet-glyph ${PLANET_COLOR_CLASS[key]}`}>
|
||||
{PLANET_GLYPH[key]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanetCell({ name, label, abbr }: { name: string | null; label: string; abbr: string }) {
|
||||
return (
|
||||
<>
|
||||
{name === null ? (
|
||||
<span className={`chart-page-planet-glyph ${ASCENDANT_COLOR_CLASS}`}>
|
||||
{ASCENDANT_MARK}
|
||||
</span>
|
||||
) : <BodyGlyph name={name} />}
|
||||
{" "}
|
||||
{label}
|
||||
{" "}
|
||||
<span className="chart-page-planet-abbr">{abbr}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
|
||||
const key = planetGlyphKey(row.name);
|
||||
return (
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<PlanetCell name={row.name} label={row.label} abbr={key ? PLANET_ABBR[key] : "—"} />
|
||||
</th>
|
||||
<td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td>
|
||||
<td className="chart-page-planet-mid">{row.retrograde ? "逆" : "顺"}</td>
|
||||
<td className="chart-page-planet-mid">{row.house}宫</td>
|
||||
<td>{row.nakshatra}</td>
|
||||
<td className="chart-page-planet-mid"><BodyGlyph name={row.nakshatraLord} /></td>
|
||||
<td className="chart-page-planet-mid chart-page-planet-num">{row.pada}</td>
|
||||
<td>{row.status}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** The ascendant is a chart point: the engine returns no nakshatra for it. */
|
||||
function AscendantRow({ row }: { row: ChartViewAscendantRow }) {
|
||||
return (
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<PlanetCell name={null} label={row.label} abbr={row.abbr} />
|
||||
</th>
|
||||
<td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td>
|
||||
<td className="chart-page-planet-mid">顺</td>
|
||||
<td className="chart-page-planet-mid">{row.house}宫</td>
|
||||
<td>{row.nakshatra ?? "—"}</td>
|
||||
<td className="chart-page-planet-mid">
|
||||
{row.nakshatraLord ? <BodyGlyph name={row.nakshatraLord} /> : "—"}
|
||||
</td>
|
||||
<td className="chart-page-planet-mid chart-page-planet-num">{row.pada ?? "—"}</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartVedicTab({
|
||||
view,
|
||||
vargaId,
|
||||
@@ -53,25 +130,27 @@ export function ChartVedicTab({
|
||||
chart can and cannot be read for. */}
|
||||
<p className="chart-page-boundary">{view.vedic.boundary}</p>
|
||||
</div>
|
||||
{/* Everything the chart stopped drawing lands here: the chart carries
|
||||
symbols, this table carries the numbers behind them. */}
|
||||
<div className="chart-page-planet-table-wrap">
|
||||
<table className="chart-page-planet-table">
|
||||
<caption>{varga.id} · {varga.meaning}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">星体</th>
|
||||
<th scope="col">星座与度数</th>
|
||||
<th scope="col">行星</th>
|
||||
<th scope="col">黄道经度</th>
|
||||
<th scope="col">速度</th>
|
||||
<th scope="col">宫位</th>
|
||||
<th scope="col">星宿</th>
|
||||
<th scope="col">宿主</th>
|
||||
<th scope="col">pada</th>
|
||||
<th scope="col">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.vedic.ascendant && <AscendantRow row={view.vedic.ascendant} />}
|
||||
{view.vedic.planets.map((planet) => (
|
||||
<tr key={planet.name}>
|
||||
<th scope="row">{planet.label}</th>
|
||||
<td>{planet.signLabel} {formatDegree(planet.degreeInSign)}</td>
|
||||
<td>{planet.house}</td>
|
||||
<td>{planet.status}</td>
|
||||
</tr>
|
||||
<PlanetRow key={planet.name} row={planet} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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