feat(chart): add a read-only natal chart page
Independent Staging Quality Gate / validate (push) Canceled after 5m23s
Independent Staging Quality Gate / publish (push) Canceled after 0s

Open /chart from the sidebar without billing or a model. Five tabs; Western and Qizheng stay unavailable until those endpoints land. Ephemeris nav points at /ephemeris (404 until that page ships).
This commit is contained in:
jesse-ux
2026-09-15 16:27:50 +08:00
parent a3dbbde4a3
commit 830799fafb
30 changed files with 6438 additions and 1 deletions
+39
View File
@@ -2,11 +2,13 @@
import { Menu } from "@base-ui/react/menu";
import {
CalendarDays,
ChevronDown,
ChevronRight,
Clock3,
FileText,
LogOut,
Orbit,
Settings,
WalletCards,
MessageSquareText,
@@ -16,6 +18,7 @@ import {
UserRound,
Users,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import type { Ref } from "react";
import {
@@ -110,6 +113,8 @@ export function AppSidebar({
onOpenLogout,
}: AppSidebarProps) {
const { isMobile, setOpen, setOpenMobile, state, viewport } = useSidebar();
const router = useRouter();
const pathname = usePathname();
const firstSessionRef = useRef<HTMLButtonElement>(null);
const historyHeadingRef = useRef<HTMLElement>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
@@ -149,6 +154,16 @@ export function AppSidebar({
if (isMobile) setOpenMobile(false);
}
function handleOpenChart() {
router.push("/chart");
if (isMobile) setOpenMobile(false);
}
function handleOpenEphemeris() {
router.push("/ephemeris");
if (isMobile) setOpenMobile(false);
}
function handleExpandHistory() {
setOpen(true);
window.requestAnimationFrame(() => {
@@ -202,6 +217,30 @@ export function AppSidebar({
{showExpandedContent ? <span>{creatingSession ? "正在创建" : "新建对话"}</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="report-nav-button"
type="button"
tooltip="星盘"
isActive={pathname === "/chart"}
onClick={handleOpenChart}
>
<Orbit size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>星盘</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="report-nav-button"
type="button"
tooltip="星历"
isActive={pathname === "/ephemeris" || pathname.startsWith("/ephemeris/")}
onClick={handleOpenEphemeris}
>
<CalendarDays size={18} strokeWidth={1.75} aria-hidden="true" />
{showExpandedContent ? <span>星历</span> : null}
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
className="report-nav-button"
@@ -0,0 +1,21 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { formatDegree } from "./chart-format";
export function ChartBasicsTab({ view }: { view: ChartViewOk }) {
return (
<div className="chart-page-basics">
<p className="chart-page-boundary">{view.vedic.symbolBoundary}</p>
<div className="chart-page-basics-grid">
{view.vedic.planets.map((planet) => (
<article key={planet.name} className="chart-page-planet-card">
<h3>{planet.label}</h3>
<p>本命 {planet.signLabel} {formatDegree(planet.degreeInSign)} · 第{planet.house}宫</p>
<p>{planet.nakshatra} 第{planet.pada}足 · 星宿主 {planet.nakshatraLord}</p>
<p>D9 {planet.d9SignLabel ?? "未返回"}</p>
{planet.symbol ? <p className="chart-page-symbol">{planet.symbol}</p> : null}
</article>
))}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
function Track({
title,
method,
currentLabel,
antardashaNote,
periods,
}: ChartViewOk["dasha"]["vimshottari"]) {
return (
<section className="chart-page-dasha-track">
<header>
<h3>{title}</h3>
<p>{method}</p>
<p>当前 {currentLabel}</p>
</header>
<ol className="chart-page-dasha-list">
{periods.map((period) => (
<li
key={`${period.lord}-${period.start}`}
className={period.current ? "chart-page-dasha-period is-current" : "chart-page-dasha-period"}
>
<p>
<strong>{period.label}</strong>
<span>{period.start} – {period.end}</span>
</p>
{period.antardashas.length > 0 ? (
<ul>
{period.antardashas.map((antar) => (
<li key={`${antar.lord}-${antar.start}`} className={antar.current ? "is-current" : undefined}>
{antar.label} · {antar.start} – {antar.end}
</li>
))}
</ul>
) : null}
</li>
))}
</ol>
{antardashaNote ? <p className="chart-page-note">{antardashaNote}</p> : null}
</section>
);
}
export function ChartDashaTab({ view }: { view: ChartViewOk }) {
return (
<div className="chart-page-dasha">
<div className="chart-page-dasha-columns">
<Track {...view.dasha.vimshottari} />
<Track {...view.dasha.chara} />
</div>
<p className="chart-page-boundary">{view.dasha.dualTrackNote}</p>
</div>
);
}
@@ -0,0 +1,9 @@
export function formatCoord(latitude: number, longitude: number): string {
const ns = latitude >= 0 ? "N" : "S";
const ew = longitude >= 0 ? "E" : "W";
return `${Math.abs(latitude).toFixed(2)}°${ns} ${Math.abs(longitude).toFixed(2)}°${ew}`;
}
export function formatDegree(degree: number): string {
return `${Math.min(29.9, Math.max(0, degree)).toFixed(1)}°`;
}
@@ -0,0 +1,85 @@
"use client";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { useState } from "react";
import {
CHART_VIEW_TABS,
type ChartViewResponse,
type ChartViewTabId,
} from "@/lib/chart-view-contract";
import { ChartBasicsTab } from "./chart-basics-tab";
import { ChartDashaTab } from "./chart-dasha-tab";
import { ChartQizhengTab } from "./chart-qizheng-tab";
import { ChartVedicTab } from "./chart-vedic-tab";
import { ChartWesternTab } from "./chart-western-tab";
export function ChartPageView({
view,
initialTab = "vedic",
}: {
view: ChartViewResponse;
initialTab?: ChartViewTabId;
}) {
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
const [vargaId, setVargaId] = useState(
view.status === "ok" ? view.vedic.vargas[0]?.id ?? "D1" : "D1",
);
return (
<main className="chart-page-shell">
<header className="chart-page-topbar">
<Link href="/" className="chart-page-back">
<ArrowLeft aria-hidden="true" />
返回对话
</Link>
</header>
{view.status !== "ok" ? (
<section className="chart-page-hero">
<p className="chart-page-eyebrow">直接计算 · 打开即有 · 不消耗点数</p>
<h1>星盘</h1>
<p>{view.message}</p>
</section>
) : (
<>
<section className="chart-page-hero">
<p className="chart-page-eyebrow">直接计算 · 打开即有 · 不消耗点数</p>
<h1>星盘</h1>
<p className="chart-page-birthline">
{view.profile.name} · {view.profile.date} {view.profile.time} · {view.profile.placeLabel}
</p>
</section>
<div className="chart-page-tabs" role="tablist" aria-label="星盘体系">
{CHART_VIEW_TABS.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
className={tab === item.id ? "chart-page-tab is-current" : "chart-page-tab"}
onClick={() => setTab(item.id)}
>
{item.label}
</button>
))}
</div>
<section className="chart-page-panel" role="tabpanel">
{tab === "vedic" ? (
<ChartVedicTab
view={view}
varga={view.vedic.vargas.find((item) => item.id === vargaId) ?? view.vedic.vargas[0]!}
onSelectVarga={setVargaId}
/>
) : null}
{tab === "basics" ? <ChartBasicsTab view={view} /> : null}
{tab === "dasha" ? <ChartDashaTab view={view} /> : null}
{tab === "western" ? <ChartWesternTab view={view} /> : null}
{tab === "qizheng" ? <ChartQizhengTab view={view} /> : null}
</section>
</>
)}
</main>
);
}
@@ -0,0 +1,31 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { formatCoord } from "./chart-format";
export function ChartParamLines({
view,
compact,
}: {
view: ChartViewOk;
compact?: boolean;
}) {
const { profile } = view;
const place = `${profile.placeLabel} · ${formatCoord(profile.latitude, profile.longitude)}`;
if (compact) {
return (
<>
<p>岁差 {profile.ayanamsaDisplay}</p>
<p>{profile.nodeModeLabel}</p>
</>
);
}
return (
<>
<p>{profile.time} · {profile.birthTimeStatusLabel}</p>
<p>{place}</p>
<p>岁差 {profile.ayanamsaDisplay}</p>
<p>{profile.nodeModeLabel}</p>
<p>升 {profile.ascendantSignLabel} · 月宿 {profile.moonNakshatra} 第{profile.moonPada}足</p>
<p>{profile.currentDashaLabel} · {profile.engineName}</p>
</>
);
}
@@ -0,0 +1,55 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY, QIZHENG_RULES } from "@/lib/chart-view-labels";
import { QizhengPalaceGrid } from "./qizheng-palace-grid";
function Rules() {
return (
<ul className="chart-page-qizheng-rules">
{QIZHENG_RULES.map((rule) => <li key={rule}>{rule}</li>)}
</ul>
);
}
export function ChartQizhengTab({ view }: { view: ChartViewOk }) {
const qizheng = view.qizheng;
if (qizheng.status === "unavailable") {
return (
<div className="chart-page-unavailable">
<p className="chart-page-boundary">{COORDINATE_BOUNDARY.qizheng}</p>
<p>{qizheng.copy}</p>
<Rules />
</div>
);
}
return (
<div className="chart-page-qizheng">
<p className="chart-page-boundary">{qizheng.boundary}</p>
<p className="chart-page-note">计都派别:{qizheng.ketuMode} · 坐标系 {qizheng.coordinateSystem}</p>
<Rules />
<QizhengPalaceGrid qizheng={qizheng} />
<table className="chart-page-planet-table">
<caption>十一曜宿度</caption>
<thead>
<tr>
<th scope="col">曜</th>
<th scope="col">宿</th>
<th scope="col">宿度</th>
<th scope="col">宫</th>
<th scope="col">庙旺</th>
</tr>
</thead>
<tbody>
{qizheng.bodies.map((body) => (
<tr key={body.name}>
<th scope="row">{body.name}</th>
<td>{body.mansion}</td>
<td>{body.mansionDegree.toFixed(2)}</td>
<td>{body.palace}</td>
<td>{body.dignityClosed ? body.dignity : `${body.dignity}(未闭合)`}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,76 @@
"use client";
import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
import type { ChartViewOk, ChartViewVarga } from "@/lib/chart-view-contract";
import { formatDegree } from "./chart-format";
import { ChartParamLines } from "./chart-param-lines";
export function ChartVedicTab({
view,
varga,
onSelectVarga,
}: {
view: ChartViewOk;
varga: ChartViewVarga;
onSelectVarga: (id: string) => void;
}) {
return (
<div className="chart-page-vedic">
<div className="chart-page-varga-chips" role="tablist" aria-label="分盘">
{view.vedic.vargas.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={item.id === varga.id}
className={item.id === varga.id ? "chart-page-chip is-current" : "chart-page-chip"}
onClick={() => onSelectVarga(item.id)}
>
{item.id}
</button>
))}
</div>
<div className="chart-page-vedic-layout">
<div className="chart-page-vedic-stage">
<VedicChartSvg chart={varga.chart} ariaLabel={`${varga.id} 北印度盘 ${varga.meaning}`} />
<div className="chart-page-center-card">
<div className="chart-page-center-compact">
<ChartParamLines view={view} compact />
</div>
<div className="chart-page-center-extra">
<ChartParamLines view={view} />
</div>
</div>
</div>
<div className="chart-page-params-below">
<ChartParamLines view={view} />
<p className="chart-page-boundary">{view.vedic.boundary}</p>
</div>
<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>
</tr>
</thead>
<tbody>
{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>
))}
</tbody>
</table>
</div>
</div>
<p className="chart-page-boundary chart-page-boundary-desktop">{view.vedic.boundary}</p>
</div>
);
}
@@ -0,0 +1,53 @@
import type { ChartViewOk } from "@/lib/chart-view-contract";
import { COORDINATE_BOUNDARY } from "@/lib/chart-view-labels";
import { WesternWheelSvg } from "./western-wheel-svg";
export function ChartWesternTab({ view }: { view: ChartViewOk }) {
const western = view.western;
if (western.status === "unavailable") {
return (
<div className="chart-page-unavailable">
<p className="chart-page-boundary">{COORDINATE_BOUNDARY.western}</p>
<p>{western.copy}</p>
</div>
);
}
return (
<div className="chart-page-western">
<div className="chart-page-western-layout">
<WesternWheelSvg western={western} />
<div className="chart-page-western-side">
<p className="chart-page-boundary">{western.boundary}</p>
<p className="chart-page-note">{western.ayanamsaGapNote}</p>
<section>
<h3>元素 / 模式</h3>
<p>{western.elements.map((item) => `${item.label} ${item.count}`).join(" · ")}</p>
<p>{western.modes.map((item) => `${item.label} ${item.count}`).join(" · ")}</p>
</section>
<section>
<h3>主要相位</h3>
<table className="chart-page-planet-table">
<thead>
<tr>
<th scope="col">星体</th>
<th scope="col">相位</th>
<th scope="col">容许度</th>
</tr>
</thead>
<tbody>
{western.aspects.slice(0, 12).map((aspect) => (
<tr key={`${aspect.left}-${aspect.right}-${aspect.aspect}`}>
<td>{aspect.left} · {aspect.right}</td>
<td>{aspect.aspectLabel}</td>
<td>{aspect.orb.toFixed(1)}°</td>
</tr>
))}
</tbody>
</table>
</section>
<p>宫制 {western.houseSystem === "P" ? "Placidus" : western.houseSystem}</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,24 @@
import type { ChartViewQizhengOk } from "@/lib/chart-view-contract";
import { QIZHENG_BRANCH_GRID } from "@/lib/chart-view-labels";
export function QizhengPalaceGrid({ qizheng }: { qizheng: ChartViewQizhengOk }) {
const byBranch = new Map(qizheng.palaces.map((palace) => [palace.branch, palace]));
return (
<div className="chart-page-qizheng-grid" role="img" aria-label="七政四余地支十二宫">
{QIZHENG_BRANCH_GRID.flatMap((row, rowIndex) => row.map((branch, columnIndex) => {
if (!branch) {
return <div key={`empty-${rowIndex}-${columnIndex}`} className="chart-page-qizheng-hole" />;
}
const palace = byBranch.get(branch);
return (
<article key={branch} className="chart-page-qizheng-cell">
<strong>{branch}</strong>
<span>{palace?.lifePalace ?? "—"}</span>
<small>{palace?.mansions.length ? palace.mansions.join(" ") : "所辖宿未返回"}</small>
<p>{palace?.occupants.length ? palace.occupants.join(" ") : "空"}</p>
</article>
);
}))}
</div>
);
}
@@ -0,0 +1,79 @@
import type { ChartViewWesternOk } from "@/lib/chart-view-contract";
import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
const SIZE = 400;
const CX = SIZE / 2;
const CY = SIZE / 2;
const OUTER = 188;
const ZODIAC = 168;
const HOUSE = 128;
const INNER = 78;
function polar(radius: number, deg: number): { x: number; y: number } {
const rad = (deg * Math.PI) / 180;
return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) };
}
function screenAngle(longitude: number, ascendant: number): number {
return 180 - (longitude - ascendant);
}
export function WesternWheelSvg({ western }: { western: ChartViewWesternOk }) {
const asc = western.ascendantLongitude;
const signWedges = SIGN_ORDER.map((sign, index) => {
const mid = screenAngle(index * 30 + 15, asc);
return { sign, label: polar((ZODIAC + OUTER) / 2, mid) };
});
return (
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
width="100%"
height="auto"
role="img"
aria-label="回归黄道本命盘,上升在左,宫位按 Placidus"
className="chart-page-western-svg"
>
<circle cx={CX} cy={CY} r={OUTER} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={ZODIAC} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={HOUSE} className="chart-page-western-ring" />
<circle cx={CX} cy={CY} r={INNER} className="chart-page-western-core" />
{signWedges.map((wedge) => {
const start = polar(ZODIAC, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc));
const outer = polar(OUTER, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc));
return (
<g key={wedge.sign}>
<line x1={start.x} y1={start.y} x2={outer.x} y2={outer.y} className="chart-page-western-spoke" />
<text x={wedge.label.x} y={wedge.label.y} textAnchor="middle" dominantBaseline="middle">
{signZh(wedge.sign)}
</text>
</g>
);
})}
{western.houses.map((house) => {
const angle = screenAngle(house.longitude, asc);
const inner = polar(INNER, angle);
const outer = polar(ZODIAC, angle);
const label = polar((INNER + HOUSE) / 2, angle);
return (
<g key={house.house}>
<line x1={inner.x} y1={inner.y} x2={outer.x} y2={outer.y} className="chart-page-western-house" />
<text x={label.x} y={label.y} textAnchor="middle" dominantBaseline="middle" className="chart-page-western-house-label">
{house.house}
</text>
</g>
);
})}
{western.planets.map((planet) => {
const point = polar((HOUSE + INNER) / 2 + 10, screenAngle(planet.longitude, asc));
return (
<text key={planet.id} x={point.x} y={point.y} textAnchor="middle" dominantBaseline="middle">
{planet.label}
</text>
);
})}
<text x={polar(OUTER + 4, 180).x - 8} y={CY} textAnchor="end" dominantBaseline="middle" className="chart-page-western-asc">
升
</text>
</svg>
);
}