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
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { loadChartView } from "@/lib/chart-view-service";
export const runtime = "nodejs";
export const maxDuration = 60;
export async function GET() {
const result = await loadChartView();
return NextResponse.json(result.body, { status: result.httpStatus });
}
+19
View File
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { ChartPageView } from "@/components/chart-page/chart-page-view";
import { loadChartView } from "@/lib/chart-view-service";
import "../site-styles";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "星盘 · Jyotisha",
robots: { index: false, follow: false },
};
export default async function ChartPage() {
const result = await loadChartView();
if (result.httpStatus === 401) redirect("/login");
return <ChartPageView view={result.body} />;
}
+330
View File
@@ -690,6 +690,22 @@ button:disabled { cursor: default; opacity: .45; }
.new-chat { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; font-size: var(--type-body-sm); line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; margin: 0; border-radius: var(--radius-md); font-weight: 500; }
.report-nav-button { width: 100%; min-height: 44px; display: flex; align-items: center; justify-content: flex-start; gap: var(--space-2); padding: 0 var(--space-3); margin: 0; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-foreground); cursor: pointer; font-size: var(--type-body-sm); font-weight: 500; line-height: 1.35; transition: background-color 120ms ease-out, transform 120ms ease-out; }
.report-nav-button:hover { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.report-nav-button[data-active="true"] {
position: relative;
background: var(--sidebar-accent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--sidebar-ring) 22%, transparent);
color: var(--sidebar-accent-foreground);
}
.report-nav-button[data-active="true"]::before {
position: absolute;
content: "";
top: var(--space-3);
bottom: var(--space-3);
left: 0;
width: 2px;
border-radius: 3px;
background: var(--sidebar-ring);
}
/* 新建对话 is the sidebar's single high-signal action. DESIGN.md keeps the
action color scarce, so it stays tinted text on a transparent row rather
than a filled block — no page-scale color surface. */
@@ -4521,6 +4537,320 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-message-wrap { display: grid; gap: var(--space-1); }
/* Read-only natal chart page. Independent route; does not grow page.tsx. */
.chart-page-shell {
width: 100%;
height: 100%;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 0 var(--space-6) var(--space-16);
background: var(--color-canvas-soft);
color: var(--color-ink);
}
.chart-page-topbar,
.chart-page-hero,
.chart-page-tabs,
.chart-page-panel {
width: min(1100px, 100%);
margin-inline: auto;
}
.chart-page-topbar {
min-height: 64px;
display: flex;
align-items: center;
}
.chart-page-back {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-weight: 500;
text-decoration: none;
}
.chart-page-back:hover { border-color: var(--color-border-strong); color: var(--color-ink); }
.chart-page-back:focus-visible,
.chart-page-tab:focus-visible,
.chart-page-chip:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}
.chart-page-back svg { width: 16px; height: 16px; }
.chart-page-hero { padding: var(--space-8) 0 var(--space-6); }
.chart-page-eyebrow {
margin: 0;
color: var(--color-ink-tertiary);
font-size: var(--type-overline);
font-weight: 500;
letter-spacing: 1.5px;
}
.chart-page-hero h1 {
margin: var(--space-3) 0 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-lg);
font-weight: 400;
letter-spacing: -1px;
line-height: 1.1;
text-wrap: balance;
}
.chart-page-hero > p:not(.chart-page-eyebrow):not(.chart-page-birthline) {
max-width: 40em;
margin: var(--space-4) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-md);
line-height: 1.65;
text-wrap: pretty;
}
.chart-page-birthline {
margin: var(--space-3) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.chart-page-tabs {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-6);
}
.chart-page-tab,
.chart-page-chip {
min-height: 44px;
min-width: 44px;
padding: 0 var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
cursor: pointer;
font-size: var(--type-body-sm);
font-weight: 500;
}
.chart-page-tab.is-current,
.chart-page-chip.is-current {
background: var(--color-selected);
border-color: var(--color-border-strong);
box-shadow: inset 2px 0 0 var(--color-focus);
color: var(--color-ink);
}
.chart-page-vedic,
.chart-page-basics,
.chart-page-dasha,
.chart-page-western,
.chart-page-qizheng,
.chart-page-western-side,
.chart-page-center-compact {
min-width: 0;
}
.chart-page-varga-chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.chart-page-vedic-stage {
position: relative;
background: var(--color-canvas);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
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; }
.chart-page-center-card {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(42%, 168px);
padding: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--color-canvas) 92%, transparent);
color: var(--color-ink-secondary);
font-size: var(--type-overline);
font-weight: 500;
letter-spacing: 0;
line-height: 1.35;
text-align: center;
}
.chart-page-center-card p { margin: 0; }
.chart-page-center-extra { display: none; }
.chart-page-params-below {
display: none;
margin-top: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.chart-page-params-below p { margin: 0 0 var(--space-2); }
.chart-page-planet-table-wrap { overflow-x: auto; }
.chart-page-planet-table {
width: 100%;
border-collapse: collapse;
background: var(--color-canvas);
font-size: var(--type-body-sm);
}
.chart-page-planet-table caption {
caption-side: top;
padding: 0 0 var(--space-3);
color: var(--color-ink-secondary);
font-size: var(--type-caption);
text-align: left;
}
.chart-page-planet-table th,
.chart-page-planet-table td {
padding: var(--space-3);
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.chart-page-planet-table thead th {
background: var(--color-canvas-muted);
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-weight: 500;
}
.chart-page-boundary {
margin: var(--space-4) 0 0;
color: var(--color-ink-tertiary);
font-size: var(--type-body-sm);
line-height: 1.55;
text-wrap: pretty;
}
.chart-page-note {
margin: var(--space-3) 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.chart-page-basics-grid,
.chart-page-dasha-columns,
.chart-page-western-layout,
.chart-page-vedic-layout {
display: grid;
gap: var(--space-4);
}
.chart-page-planet-card,
.chart-page-dasha-track,
.chart-page-unavailable {
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-canvas);
}
.chart-page-planet-card h3,
.chart-page-dasha-track h3,
.chart-page-western-side h3 {
margin: 0 0 var(--space-2);
font-family: var(--font-display);
font-size: var(--type-title-md);
font-weight: 400;
}
.chart-page-planet-card p { margin: 0 0 var(--space-2); font-size: var(--type-body-sm); }
.chart-page-symbol { color: var(--color-ink-tertiary); }
.chart-page-dasha-list { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--space-2); }
.chart-page-dasha-period {
padding: var(--space-3);
border: 1px solid transparent;
border-radius: var(--radius-sm);
}
.chart-page-dasha-period.is-current {
background: var(--color-selected);
border-color: var(--color-border-strong);
box-shadow: inset 2px 0 0 var(--color-focus);
}
.chart-page-dasha-period p {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0;
font-size: var(--type-body-sm);
}
.chart-page-dasha-period ul {
margin: var(--space-2) 0 0;
padding: 0;
list-style: none;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.chart-page-dasha-period ul li.is-current { color: var(--color-ink); font-weight: 500; }
.chart-page-western-svg { display: block; width: 100%; height: auto; color: var(--color-ink); }
.chart-page-western-ring,
.chart-page-western-spoke,
.chart-page-western-house {
fill: none;
stroke: var(--color-border-strong);
stroke-width: 1.25;
}
.chart-page-western-core { fill: var(--color-canvas-muted); stroke: var(--color-border-strong); }
.chart-page-western-svg text { fill: currentColor; font-size: 16px; }
.chart-page-western-house-label,
.chart-page-western-asc { fill: var(--color-ink-tertiary); }
.chart-page-qizheng-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-2);
margin: var(--space-4) 0;
}
.chart-page-qizheng-cell {
min-height: 88px;
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
font-size: var(--type-body-sm);
}
.chart-page-qizheng-cell strong { display: block; font-size: var(--type-title-sm); font-weight: 500; }
.chart-page-qizheng-cell span,
.chart-page-qizheng-cell small,
.chart-page-qizheng-cell p { display: block; margin: var(--space-1) 0 0; color: var(--color-ink-secondary); }
.chart-page-qizheng-cell small { font-size: var(--type-caption); }
.chart-page-qizheng-hole { min-height: 0; }
.chart-page-qizheng-rules {
margin: var(--space-3) 0;
padding-left: 1.2em;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
}
.chart-page-unavailable > p { margin: 0 0 var(--space-3); font-size: var(--type-body-md); }
@media (max-width: 767px) {
.chart-page-hero h1 { font-size: var(--type-display-md); }
.chart-page-center-compact { display: block; }
.chart-page-center-extra { display: none; }
.chart-page-params-below { display: block; }
.chart-page-boundary-desktop { display: none; }
}
@media (min-width: 768px) {
.chart-page-center-compact { display: none; }
.chart-page-center-extra { display: block; }
.chart-page-params-below { display: none; }
.chart-page-basics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.chart-page-vedic-layout,
.chart-page-dasha-columns,
.chart-page-western-layout {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: var(--space-6);
align-items: start;
}
.chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (prefers-reduced-motion: reduce) {
.agent-activity-status__text {
animation: none;
+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>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { z } from "zod";
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
export const chartViewUnavailableLayerSchema = z.object({
status: z.literal("unavailable"),
reason: z.string().min(1),
copy: z.string().min(1),
});
export const northIndianChartSchema = z.object({
houses: z.array(z.object({
houseNumber: z.number().int().min(1).max(12),
sign: z.string().min(1),
occupants: z.array(z.string()),
})).length(12),
planets: z.array(z.object({
name: z.string().min(1),
retrograde: z.boolean(),
})).optional(),
});
export const chartViewPlanetRowSchema = z.object({
name: z.string().min(1),
label: z.string().min(1),
sign: z.string().min(1),
signLabel: z.string().min(1),
degreeInSign: z.number(),
house: z.number().int().min(1).max(12),
retrograde: z.boolean(),
status: z.string().min(1),
nakshatra: z.string().min(1),
pada: z.number().int().min(1).max(4),
nakshatraLord: z.string().min(1),
d9Sign: z.string().nullable(),
d9SignLabel: z.string().nullable(),
symbol: z.string().nullable(),
});
export const chartViewVargaSchema = z.object({
id: z.string().regex(/^D\d{1,3}$/),
title: z.string().min(1),
meaning: z.string().min(1),
division: z.number().int().positive(),
chart: northIndianChartSchema,
});
export const chartViewPeriodSchema = z.object({
lord: z.string().min(1),
label: z.string().min(1),
start: isoDate,
end: isoDate,
current: z.boolean(),
derived: z.boolean(),
});
export const chartViewMahadashaSchema = chartViewPeriodSchema.extend({
sign: z.string().nullable(),
antardashas: z.array(chartViewPeriodSchema),
});
export const chartViewDashaTrackSchema = z.object({
id: z.enum(["vimshottari", "chara"]),
title: z.string().min(1),
method: z.string().min(1),
currentLabel: z.string().min(1),
antardashaNote: z.string().nullable(),
periods: z.array(chartViewMahadashaSchema),
});
export const westernPointSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
longitude: z.number(),
sign: z.string().min(1),
signLabel: z.string().min(1),
degreeInSign: z.number(),
house: z.number().int().min(1).max(12).nullable(),
retrograde: z.boolean(),
});
export const chartViewWesternOkSchema = z.object({
status: z.literal("ok"),
zodiac: z.literal("tropical"),
houseSystem: z.string().min(1),
boundary: z.string().min(1),
ayanamsaGapNote: z.string().min(1),
ascendantLongitude: z.number(),
planets: z.array(westernPointSchema),
houses: z.array(z.object({
house: z.number().int().min(1).max(12),
longitude: z.number(),
sign: z.string().min(1),
signLabel: z.string().min(1),
degreeInSign: z.number(),
})).length(12),
aspects: z.array(z.object({
left: z.string().min(1),
right: z.string().min(1),
aspect: z.string().min(1),
aspectLabel: z.string().min(1),
orb: z.number(),
})),
elements: z.array(z.object({
id: z.string().min(1),
label: z.string().min(1),
count: z.number().int().nonnegative(),
})),
modes: z.array(z.object({
id: z.string().min(1),
label: z.string().min(1),
count: z.number().int().nonnegative(),
})),
});
export const chartViewQizhengOkSchema = z.object({
status: z.literal("ok"),
coordinateSystem: z.string().min(1),
ketuMode: z.string().min(1),
boundary: z.string().min(1),
palaces: z.array(z.object({
branch: z.string().min(1),
lifePalace: z.string().min(1),
mansions: z.array(z.string()),
occupants: z.array(z.string()),
})),
bodies: z.array(z.object({
name: z.string().min(1),
mansion: z.string().min(1),
mansionDegree: z.number(),
palace: z.string().min(1),
dignity: z.string().min(1),
dignityClosed: z.boolean(),
})),
});
export const chartViewProfileSchema = z.object({
name: z.string().min(1),
date: isoDate,
time: z.string().regex(/^\d{2}:\d{2}/),
placeLabel: z.string().min(1),
latitude: z.number(),
longitude: z.number(),
timezoneLabel: z.string().min(1),
ayanamsa: z.string().min(1),
ayanamsaDisplay: z.string().min(1),
nodeMode: z.enum(["mean", "true"]),
nodeModeLabel: z.string().min(1),
birthTimeStatus: z.string().min(1),
birthTimeStatusLabel: z.string().min(1),
engineName: z.string().min(1),
moonNakshatra: z.string().min(1),
moonPada: z.number().int().min(1).max(4),
ascendantSign: z.string().min(1),
ascendantSignLabel: z.string().min(1),
currentDashaLabel: z.string().min(1),
});
export const chartViewOkSchema = z.object({
status: z.literal("ok"),
billed: z.literal(false),
profile: chartViewProfileSchema,
vedic: z.object({
coordinateSystem: z.literal("sidereal"),
boundary: z.string().min(1),
vargas: z.array(chartViewVargaSchema).min(1),
planets: z.array(chartViewPlanetRowSchema).min(1),
symbolBoundary: z.string().min(1),
}),
dasha: z.object({
vimshottari: chartViewDashaTrackSchema,
chara: chartViewDashaTrackSchema,
dualTrackNote: z.string().min(1),
}),
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
qizheng: z.union([chartViewQizhengOkSchema, chartViewUnavailableLayerSchema]),
});
export const chartViewMessageSchema = z.object({
status: z.enum(["unauthenticated", "birth_profile_incomplete", "chart_unavailable", "rate_limited"]),
billed: z.literal(false),
message: z.string().min(1),
});
export const chartViewResponseSchema = z.union([chartViewOkSchema, chartViewMessageSchema]);
export type ChartViewOk = z.infer<typeof chartViewOkSchema>;
export type ChartViewMessage = z.infer<typeof chartViewMessageSchema>;
export type ChartViewResponse = z.infer<typeof chartViewResponseSchema>;
export type ChartViewUnavailableLayer = z.infer<typeof chartViewUnavailableLayerSchema>;
export type ChartViewWesternOk = z.infer<typeof chartViewWesternOkSchema>;
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 const CHART_VIEW_TABS = [
{ id: "vedic", label: "星盘" },
{ id: "basics", label: "基础信息" },
{ id: "dasha", label: "大运" },
{ id: "western", label: "西洋盘" },
{ id: "qizheng", label: "七政四余" },
] as const;
export type ChartViewTabId = (typeof CHART_VIEW_TABS)[number]["id"];
+169
View File
@@ -0,0 +1,169 @@
export const PLANET_ORDER = [
"Sun",
"Moon",
"Mars",
"Mercury",
"Jupiter",
"Venus",
"Saturn",
"Rahu",
"Ketu",
] as const;
export type PlanetName = (typeof PLANET_ORDER)[number];
export const PLANET_ZH: Record<PlanetName, string> = {
Sun: "太阳",
Moon: "月亮",
Mars: "火星",
Mercury: "水星",
Jupiter: "木星",
Venus: "金星",
Saturn: "土星",
Rahu: "罗睺",
Ketu: "计都",
};
export const SIGN_ORDER = [
"Aries",
"Taurus",
"Gemini",
"Cancer",
"Leo",
"Virgo",
"Libra",
"Scorpio",
"Sagittarius",
"Capricorn",
"Aquarius",
"Pisces",
] as const;
export type SignName = (typeof SIGN_ORDER)[number];
export const SIGN_ZH: Record<SignName, string> = {
Aries: "白羊",
Taurus: "金牛",
Gemini: "双子",
Cancer: "巨蟹",
Leo: "狮子",
Virgo: "处女",
Libra: "天秤",
Scorpio: "天蝎",
Sagittarius: "射手",
Capricorn: "摩羯",
Aquarius: "水瓶",
Pisces: "双鱼",
};
export const VARGA_CHIP_ORDER = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 20, 24, 27, 30,
] as const;
export const VIMSHOTTARI_ORDER = [
"Ketu",
"Venus",
"Sun",
"Moon",
"Mars",
"Rahu",
"Jupiter",
"Saturn",
"Mercury",
] as const;
export const VIMSHOTTARI_YEARS: Record<(typeof VIMSHOTTARI_ORDER)[number], number> = {
Ketu: 7,
Venus: 20,
Sun: 6,
Moon: 10,
Mars: 7,
Rahu: 18,
Jupiter: 16,
Saturn: 19,
Mercury: 17,
};
export const WESTERN_BODY_ZH: Record<string, string> = {
sun: "太阳",
moon: "月亮",
mercury: "水星",
venus: "金星",
mars: "火星",
jupiter: "木星",
saturn: "土星",
uranus: "天王",
neptune: "海王",
pluto: "冥王",
true_node: "北交",
ascendant: "上升",
mc: "中天",
};
export const ASPECT_ZH: Record<string, string> = {
conjunction: "合相",
sextile: "六分相",
square: "刑相",
trine: "三分相",
opposition: "对相",
};
export const ELEMENT_ZH: Record<string, string> = {
fire: "火",
earth: "土",
air: "风",
water: "水",
};
export const MODE_ZH: Record<string, string> = {
cardinal: "基本",
fixed: "固定",
mutable: "变动",
};
export const QIZHENG_BRANCH_GRID: ReadonlyArray<readonly (string | null)[]> = [
["巳", "午", "未", "申"],
["辰", null, null, "酉"],
["卯", null, null, "戌"],
["寅", "丑", "子", "亥"],
];
export const COORDINATE_BOUNDARY = {
vedic: "这是恒星黄道,岁差用你资料里选的那一套。它和西洋回归黄道、七政宿度不是同一套坐标,不能换算,也不能叠成双重印证。",
western: "这是回归黄道,宫位用 Placidus。它和印度盘的恒星黄经、七政宿度不是同一套,不能换算,也不能叠成双重印证。",
qizheng: "宿度从角宿初度起算,不是恒星黄经,也不是回归黄道。三套不能换算,也不能叠成双重印证。",
} as const;
export const DASHA_DUAL_TRACK_NOTE = "只有两条同时指向同一段时间才算证据,单轨命中要降一级来看。";
export const ANTARDASHA_DERIVED_NOTE = "分运按标准比例从大运边界推出,不是引擎单独返回的字段。";
export const CHARA_ANTARDASHA_DATE_NOTE = "分运时长来自引擎的十二等分;起止日期按大运边界推算,不是引擎单独返回的日历字段。";
export const SYMBOL_BOUNDARY = "下面是词条式释义,不是对你个人的判断。";
export const WESTERN_UNAVAILABLE_COPY = "西洋盘这一栏要等回归黄道端点上线。现在还没有,先空着——不会拿印度盘的度数改写成回归黄道。";
export const QIZHENG_UNAVAILABLE_COPY = "七政四余这一栏要等宿度端点上线。现在还没有,先空着。";
export const QIZHENG_RULES = [
"宿度从角宿初度起算,不是恒星黄经。",
"计都派别(是否取罗睺对点)会改整张盘,必须写明,不能静默默认。",
"庙旺表还没闭合,这一列只能当占位,不能拿来下判断。",
] as const;
export function planetZh(name: string): string {
return PLANET_ZH[name as PlanetName] ?? name;
}
export function signZh(sign: string): string {
return SIGN_ZH[sign as SignName] ?? sign;
}
export function isSignName(value: string): value is SignName {
return (SIGN_ORDER as readonly string[]).includes(value);
}
export function isPlanetName(value: string): value is PlanetName {
return (PLANET_ORDER as readonly string[]).includes(value);
}
+124
View File
@@ -0,0 +1,124 @@
import { resolveAyanamsa } from "./ayanamsa.ts";
import {
chartViewMessageSchema,
type ChartViewResponse,
} from "./chart-view-contract.ts";
import { VARGA_CHIP_ORDER } from "./chart-view-labels.ts";
import {
buildChartView,
engineChartHasPlanets,
type ChartViewProfileInput,
} from "./chart-view-mapper.ts";
export type ChartViewEnginePost = (
path: string,
body: Record<string, unknown>,
) => Promise<Record<string, unknown> | null>;
export type ChartViewLoadInput = {
userId: string | null;
profile: ChartViewProfileInput | null;
postEngine: ChartViewEnginePost;
asOf: string;
rateLimited?: boolean;
};
function message(
status: "unauthenticated" | "birth_profile_incomplete" | "chart_unavailable" | "rate_limited",
copy: string,
): ChartViewResponse {
return chartViewMessageSchema.parse({ status, billed: false, message: copy });
}
function birthPayload(profile: ChartViewProfileInput): Record<string, unknown> {
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = profile.time.split(":").map(Number);
return {
year,
month,
day,
hour,
minute,
second: 0,
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezoneOffset,
latitude: profile.latitude,
longitude: profile.longitude,
timezone: profile.timezoneOffset,
ayanamsa: resolveAyanamsa({ ayanamsa: profile.ayanamsa }),
node_mode: "mean",
house_system: "P",
};
}
export async function assembleChartView(input: ChartViewLoadInput): Promise<{
httpStatus: number;
body: ChartViewResponse;
}> {
if (!input.userId) {
return {
httpStatus: 401,
body: message("unauthenticated", "请先登录后再看星盘。"),
};
}
if (input.rateLimited) {
return {
httpStatus: 429,
body: message("rate_limited", "刚才打开得太勤,稍等再试。"),
};
}
const profile = input.profile;
if (!profile) {
return {
httpStatus: 200,
body: message("birth_profile_incomplete", "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。"),
};
}
const payload = birthPayload(profile);
const chart = await input.postEngine("/api/chart", payload);
if (!chart || chart.success === false || !engineChartHasPlanets(chart)) {
return {
httpStatus: 200,
body: message("chart_unavailable", "这张盘这会儿算不出来。资料还在,过一会儿再打开。"),
};
}
const planets = chart.planets;
const ascendant = chart.ascendant;
const houses = chart.houses;
const followUp = {
...payload,
planets,
ascendant,
houses,
};
const [chara, varga, western, qizheng] = await Promise.all([
input.postEngine("/api/dasha/chara", { ...followUp, antardasha: true, mode: "dasha" }),
input.postEngine("/api/varga_full", {
...followUp,
divisions: VARGA_CHIP_ORDER.map((division) => `D${division}`),
}),
input.postEngine("/api/western", payload),
input.postEngine("/api/qizheng", payload),
]);
try {
const body = buildChartView({
chart,
varga,
chara,
western,
qizheng,
profile,
asOf: input.asOf,
});
return { httpStatus: 200, body };
} catch {
return {
httpStatus: 200,
body: message("chart_unavailable", "这张盘这会儿算不出来。资料还在,过一会儿再打开。"),
};
}
}
+670
View File
@@ -0,0 +1,670 @@
import {
toNorthIndianChart,
type ReportChartBlock,
} from "./report-chart-block.ts";
import {
ANTARDASHA_DERIVED_NOTE,
CHARA_ANTARDASHA_DATE_NOTE,
COORDINATE_BOUNDARY,
DASHA_DUAL_TRACK_NOTE,
PLANET_ORDER,
QIZHENG_UNAVAILABLE_COPY,
SIGN_ORDER,
SYMBOL_BOUNDARY,
VARGA_CHIP_ORDER,
VIMSHOTTARI_ORDER,
VIMSHOTTARI_YEARS,
WESTERN_UNAVAILABLE_COPY,
ASPECT_ZH,
ELEMENT_ZH,
MODE_ZH,
WESTERN_BODY_ZH,
isSignName,
planetZh,
signZh,
} from "./chart-view-labels.ts";
import { traditionalSymbol } from "./chart-view-symbols.ts";
import {
chartViewOkSchema,
type ChartViewOk,
type ChartViewQizhengOk,
type ChartViewUnavailableLayer,
type ChartViewVarga,
type ChartViewWesternOk,
type NorthIndianChart,
} from "./chart-view-contract.ts";
export type ChartViewProfileInput = {
name?: string;
date: string;
time: string;
placeLabel?: string;
latitude: number;
longitude: number;
timezoneOffset: number;
timezoneId?: string;
ayanamsa?: string;
birthTimeStatus?: string;
};
export type ChartViewEngineBundle = {
chart: Record<string, unknown>;
varga: Record<string, unknown> | null;
chara: Record<string, unknown> | null;
western: Record<string, unknown> | null;
qizheng: Record<string, unknown> | null;
profile: ChartViewProfileInput;
asOf: string;
};
function record(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function finite(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function isoDay(value: string): string {
return value.slice(0, 10);
}
function addWeightedSpan(startIso: string, endIso: string, index: number, weights: readonly number[]): { start: string; end: string } {
const start = Date.parse(`${startIso}T00:00:00Z`);
const end = Date.parse(`${endIso}T00:00:00Z`);
const total = weights.reduce((sum, weight) => sum + weight, 0);
const before = weights.slice(0, index).reduce((sum, weight) => sum + weight, 0);
const span = end - start;
const from = start + span * (before / total);
const to = index === weights.length - 1 ? end : start + span * ((before + weights[index]!) / total);
return { start: new Date(from).toISOString().slice(0, 10), end: new Date(to).toISOString().slice(0, 10) };
}
function addYears(startIso: string, years: number): string {
const start = Date.parse(`${startIso}T00:00:00Z`);
return new Date(start + years * 365.25 * 86_400_000).toISOString().slice(0, 10);
}
function containsDay(start: string, end: string, asOf: string): boolean {
return start <= asOf && asOf < end;
}
function asPlanetMap(value: unknown): Record<string, Record<string, unknown>> {
const raw = record(value);
if (!raw) return {};
const out: Record<string, Record<string, unknown>> = {};
for (const [name, planet] of Object.entries(raw)) {
const row = record(planet);
if (row) out[name] = row;
}
return out;
}
function planetLon(planet: Record<string, unknown>): number | null {
return finite(planet.lon) ?? finite(planet.longitude) ?? finite(planet.degree_raw);
}
function planetDegreeInSign(planet: Record<string, unknown>): number {
return finite(planet.degree_in_sign) ?? finite(planet.degree) ?? ((planetLon(planet) ?? 0) % 30);
}
function statusLabel(planet: Record<string, unknown>): string {
const retrograde = Boolean(planet.retrograde);
const dignity = text(planet.status) ?? "中性";
return retrograde && !dignity.includes("逆") ? `${dignity} · 逆行` : dignity;
}
function birthTimeStatusLabel(status: string): string {
if (status === "confirmed") return "已校正";
if (status === "accepted") return "已采用";
return "填报时间";
}
function nodeModeLabel(mode: "mean" | "true"): string {
return mode === "true" ? "真交点" : "平交点";
}
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}`;
}
function houseFromPlanet(planet: Record<string, unknown>, ascSign: string): number {
const direct = finite(planet.house);
if (direct !== null && direct >= 1 && direct <= 12) return Math.trunc(direct);
const sign = text(planet.sign);
if (!sign || !isSignName(sign) || !isSignName(ascSign)) return 1;
return ((SIGN_ORDER.indexOf(sign) - SIGN_ORDER.indexOf(ascSign) + 12) % 12) + 1;
}
function natalBlock(chart: Record<string, unknown>): ReportChartBlock | null {
const planets = asPlanetMap(chart.planets);
const ascendant = record(chart.ascendant);
const ascSign = text(ascendant?.sign);
if (!ascSign || !isSignName(ascSign)) return null;
const rows: ReportChartBlock["planets"] = [];
for (const name of PLANET_ORDER) {
const planet = planets[name];
if (!planet) continue;
const sign = text(planet.sign);
if (!sign || !isSignName(sign)) continue;
rows.push({
name,
sign,
degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))),
retrograde: Boolean(planet.retrograde),
});
}
if (rows.length === 0) return null;
return {
version: 1,
id: "D1",
title: "D1 — 本命盘",
layout: "north",
ascendant: {
sign: ascSign,
degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree_in_sign) ?? finite(ascendant?.degree) ?? 0)),
},
planets: rows,
};
}
function vargaKey(division: number, result: Record<string, unknown>): Record<string, unknown> | null {
const exact = Object.entries(result).find(([key]) => key === `D${division}` || key.startsWith(`D${division}_`));
return exact ? record(exact[1]) : null;
}
function vargaToChart(
id: string,
title: string,
varga: Record<string, unknown>,
natalRetro: ReadonlyMap<string, boolean>,
): { chart: NorthIndianChart; meaning: string; division: number } | null {
const ascendant = record(varga.ascendant);
const ascSign = text(ascendant?.sign);
if (!ascSign || !isSignName(ascSign)) return null;
const planets = asPlanetMap(varga.planets);
const rows: ReportChartBlock["planets"] = [];
for (const name of PLANET_ORDER) {
const planet = planets[name];
if (!planet) continue;
const sign = text(planet.sign);
if (!sign || !isSignName(sign)) continue;
rows.push({
name,
sign,
degree: Math.min(29.999, Math.max(0, planetDegreeInSign(planet))),
retrograde: natalRetro.get(name) ?? false,
});
}
const division = finite(varga.division) ?? Number(id.slice(1));
const meaning = text(varga.meaning) ?? title;
const block: ReportChartBlock = {
version: 1,
id: id as ReportChartBlock["id"],
title,
layout: "north",
ascendant: {
sign: ascSign,
degree: Math.min(29.999, Math.max(0, finite(ascendant?.degree) ?? 0)),
},
planets: rows,
};
return { chart: toNorthIndianChart(block), meaning, division };
}
function buildVargas(
chart: Record<string, unknown>,
vargaPacket: Record<string, unknown> | null,
): ChartViewVarga[] {
const natalRetro = new Map<string, boolean>();
for (const [name, planet] of Object.entries(asPlanetMap(chart.planets))) {
natalRetro.set(name, Boolean(planet.retrograde));
}
const result = record(vargaPacket?.result) ?? {};
const vargas: ChartViewVarga[] = [];
for (const division of VARGA_CHIP_ORDER) {
const raw = vargaKey(division, result);
const id = `D${division}`;
if (raw) {
const mapped = vargaToChart(id, `${id} ${text(raw.name) ?? ""}`.trim(), raw, natalRetro);
if (mapped) {
vargas.push({
id,
title: id,
meaning: mapped.meaning,
division: mapped.division,
chart: mapped.chart,
});
continue;
}
}
if (division === 1) {
const block = natalBlock(chart);
if (block) {
vargas.push({
id: "D1",
title: "D1",
meaning: "本命盘",
division: 1,
chart: toNorthIndianChart(block),
});
}
}
}
return vargas;
}
function d9SignFor(name: string, vargaPacket: Record<string, unknown> | null): string | null {
const result = record(vargaPacket?.result) ?? {};
const d9 = vargaKey(9, result);
const planet = asPlanetMap(d9?.planets)[name];
return text(planet?.sign);
}
function planetRows(chart: Record<string, unknown>, vargaPacket: Record<string, unknown> | null) {
const planets = asPlanetMap(chart.planets);
const ascSign = text(record(chart.ascendant)?.sign) ?? "Aries";
return PLANET_ORDER.flatMap((name) => {
const planet = planets[name];
if (!planet) return [];
const sign = text(planet.sign) ?? "Aries";
const d9 = d9SignFor(name, vargaPacket);
return [{
name,
label: planetZh(name),
sign,
signLabel: signZh(sign),
degreeInSign: planetDegreeInSign(planet),
house: houseFromPlanet(planet, ascSign),
retrograde: Boolean(planet.retrograde),
status: statusLabel(planet),
nakshatra: text(planet.nakshatra) ?? "—",
pada: Math.min(4, Math.max(1, Math.trunc(finite(planet.nakshatra_pada) ?? 1))),
nakshatraLord: planetZh(text(planet.nakshatra_lord) ?? "—"),
d9Sign: d9,
d9SignLabel: d9 ? signZh(d9) : null,
symbol: traditionalSymbol(name, sign),
}];
});
}
function vimshottariTrack(chart: Record<string, unknown>, asOf: string) {
const dasha = record(chart.dasha) ?? {};
const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : [];
const periods = rawPeriods.flatMap((entry) => {
const row = record(entry);
if (!row) return [];
const lord = text(row.lord);
const start = text(row.start);
const end = text(row.end);
if (!lord || !start || !end) return [];
const startIso = isoDay(start);
const endIso = isoDay(end);
const startIndex = VIMSHOTTARI_ORDER.indexOf(lord as (typeof VIMSHOTTARI_ORDER)[number]);
const order = startIndex >= 0
? VIMSHOTTARI_ORDER.map((_, index) => VIMSHOTTARI_ORDER[(startIndex + index) % VIMSHOTTARI_ORDER.length]!)
: [...VIMSHOTTARI_ORDER];
const weights = order.map((name) => VIMSHOTTARI_YEARS[name]);
const antardashas = order.map((subLord, index) => {
const span = addWeightedSpan(startIso, endIso, index, weights);
return {
lord: subLord,
label: planetZh(subLord),
start: span.start,
end: span.end,
current: containsDay(span.start, span.end, asOf),
derived: true,
};
});
return [{
lord,
label: planetZh(lord),
sign: null,
start: startIso,
end: endIso,
current: containsDay(startIso, endIso, asOf),
derived: false,
antardashas,
}];
});
const current = periods.find((item) => item.current);
const currentAntar = current?.antardashas.find((item) => item.current);
const currentLabel = current
? `${planetZh(current.lord)}大运 / ${currentAntar ? planetZh(currentAntar.lord) : "—"}分运`
: "当前大运未落入已返回的时段";
return {
id: "vimshottari" as const,
title: "Vimshottari",
method: "Vimshottari",
currentLabel,
antardashaNote: ANTARDASHA_DERIVED_NOTE,
periods,
};
}
function charaTrack(charaPacket: Record<string, unknown> | null, birthDate: string, asOf: string) {
const result = record(charaPacket?.result) ?? charaPacket;
const chara = record(result?.chara_dasha) ?? result;
const sequence = Array.isArray(chara?.dasha_sequence) ? chara.dasha_sequence : [];
let cursor = birthDate;
const periods = sequence.flatMap((entry) => {
const row = record(entry);
if (!row) return [];
const sign = text(row.sign);
const lord = text(row.lord) ?? sign ?? "—";
const duration = finite(row.duration_years) ?? 1;
const start = cursor;
const end = addYears(start, duration);
cursor = end;
const rawAntar = Array.isArray(row.antardashas) ? row.antardashas : [];
const antardashas = rawAntar.length > 0
? rawAntar.flatMap((antar, index) => {
const item = record(antar);
if (!item) return [];
const weights = rawAntar.map((candidate) => finite(record(candidate)?.duration_years) ?? 1);
const span = addWeightedSpan(start, end, index, weights);
const antarLord = text(item.lord) ?? text(item.sign) ?? "—";
return [{
lord: antarLord,
label: `${signZh(text(item.sign) ?? antarLord)} / ${planetZh(antarLord)}`,
start: span.start,
end: span.end,
current: containsDay(span.start, span.end, asOf),
derived: true,
}];
})
: SIGN_ORDER.map((antarSign, index) => {
const span = addWeightedSpan(start, end, index, SIGN_ORDER.map(() => 1));
return {
lord: antarSign,
label: signZh(antarSign),
start: span.start,
end: span.end,
current: containsDay(span.start, span.end, asOf),
derived: true,
};
});
return [{
lord,
label: `${signZh(sign ?? lord)} / ${planetZh(lord)}`,
sign: sign,
start,
end,
current: containsDay(start, end, asOf),
derived: false,
antardashas,
}];
});
const current = periods.find((item) => item.current);
const currentAntar = current?.antardashas.find((item) => item.current);
const currentLabel = current
? `${current.label}${currentAntar ? ` / ${currentAntar.label}` : ""}`
: "当前 Chara 段未落入已返回的时段";
return {
id: "chara" as const,
title: "Chara Dashakn_rao 变体)",
method: text(chara?.method) === "Chara Dasha (kn_rao)"
? "Chara Dashakn_rao 变体)"
: (text(chara?.method) ?? "Chara Dashakn_rao 变体)"),
currentLabel,
antardashaNote: CHARA_ANTARDASHA_DATE_NOTE,
periods,
};
}
function unavailableWestern(reason: string): ChartViewUnavailableLayer {
return { status: "unavailable", reason, copy: WESTERN_UNAVAILABLE_COPY };
}
function unavailableQizheng(reason: string): ChartViewUnavailableLayer {
return { status: "unavailable", reason, copy: QIZHENG_UNAVAILABLE_COPY };
}
function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string): ChartViewWesternOk | ChartViewUnavailableLayer {
if (!raw) return unavailableWestern("endpoint_missing");
const natal = record(raw.natal) ?? raw;
const ascendant = record(natal.ascendant);
const planetsRaw = record(natal.planets);
const housesRaw = Array.isArray(natal.houses) ? natal.houses : [];
if (!ascendant || !planetsRaw || housesRaw.length !== 12) return unavailableWestern("payload_incomplete");
const ascLon = finite(ascendant.longitude);
if (ascLon === null) return unavailableWestern("payload_incomplete");
const planets = Object.entries(planetsRaw).flatMap(([id, value]) => {
const planet = record(value);
if (!planet) return [];
const longitude = finite(planet.longitude);
const sign = text(planet.sign);
if (longitude === null || !sign) return [];
return [{
id,
label: WESTERN_BODY_ZH[id] ?? id,
longitude,
sign,
signLabel: signZh(sign),
degreeInSign: finite(planet.degree_in_sign) ?? (longitude % 30),
house: finite(planet.house) !== null ? Math.trunc(finite(planet.house)!) : null,
retrograde: Boolean(planet.retrograde),
}];
});
const houses = housesRaw.flatMap((entry, index) => {
const row = record(entry);
const cusp = record(row?.cusp) ?? row;
if (!cusp) return [];
const longitude = finite(cusp.longitude);
const sign = text(cusp.sign);
if (longitude === null || !sign) return [];
return [{
house: Math.trunc(finite(row?.house) ?? index + 1),
longitude,
sign,
signLabel: signZh(sign),
degreeInSign: finite(cusp.degree_in_sign) ?? (longitude % 30),
}];
});
if (planets.length === 0 || houses.length !== 12) return unavailableWestern("payload_incomplete");
const aspectsRaw = Array.isArray(natal.aspects) ? natal.aspects : [];
const aspects = aspectsRaw.flatMap((entry) => {
const row = record(entry);
if (!row) return [];
const left = text(row.left);
const right = text(row.right);
const aspect = text(row.aspect);
if (!left || !right || !aspect) return [];
return [{
left: WESTERN_BODY_ZH[left] ?? left,
right: WESTERN_BODY_ZH[right] ?? right,
aspect,
aspectLabel: ASPECT_ZH[aspect] ?? aspect,
orb: finite(row.orb) ?? 0,
}];
});
const distribution = record(natal.distribution);
const elementsRaw = record(distribution?.elements) ?? {};
const modesRaw = record(distribution?.modes) ?? {};
return {
status: "ok",
zodiac: "tropical",
houseSystem: text(raw.house_system) ?? "P",
boundary: COORDINATE_BOUNDARY.western,
ayanamsaGapNote: `回归黄道与本页印度盘所用${ayanamsaDisplay}岁差不是同一套;差值随日期变,不能把两边度数对过去。`,
ascendantLongitude: ascLon,
planets,
houses,
aspects,
elements: ["fire", "earth", "air", "water"].map((id) => ({
id,
label: ELEMENT_ZH[id] ?? id,
count: Math.max(0, Math.trunc(finite(elementsRaw[id]) ?? 0)),
})),
modes: ["cardinal", "fixed", "mutable"].map((id) => ({
id,
label: MODE_ZH[id] ?? id,
count: Math.max(0, Math.trunc(finite(modesRaw[id]) ?? 0)),
})),
};
}
function palaceBranch(row: Record<string, unknown>): string | null {
return text(row.branch) ?? text(row.zhi) ?? text(row.earthly_branch) ?? text(row.name);
}
function mapQizheng(raw: Record<string, unknown> | null): ChartViewQizhengOk | ChartViewUnavailableLayer {
if (!raw) return unavailableQizheng("endpoint_missing");
const palacesRaw = raw.palaces;
const bodiesRaw = raw.bodies ?? raw.planets;
const palaces: ChartViewQizhengOk["palaces"] = [];
if (Array.isArray(palacesRaw)) {
for (const entry of palacesRaw) {
const row = record(entry);
if (!row) continue;
const branch = palaceBranch(row);
if (!branch) continue;
palaces.push({
branch,
lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? text(row.human_palace) ?? "—",
mansions: Array.isArray(row.mansions)
? row.mansions.filter((item): item is string => typeof item === "string")
: text(row.mansions)
? [String(row.mansions)]
: (text(row.xiu) ? [String(row.xiu)] : []),
occupants: Array.isArray(row.occupants)
? row.occupants.filter((item): item is string => typeof item === "string")
: [],
});
}
} else {
const asRecord = record(palacesRaw);
if (asRecord) {
for (const [branch, value] of Object.entries(asRecord)) {
const row = record(value) ?? {};
palaces.push({
branch,
lifePalace: text(row.life_palace) ?? text(row.lifePalace) ?? "—",
mansions: Array.isArray(row.mansions)
? row.mansions.filter((item): item is string => typeof item === "string")
: [],
occupants: Array.isArray(row.occupants)
? row.occupants.filter((item): item is string => typeof item === "string")
: [],
});
}
}
}
const bodies: ChartViewQizhengOk["bodies"] = [];
if (Array.isArray(bodiesRaw)) {
for (const entry of bodiesRaw) {
const row = record(entry);
if (!row) continue;
const name = text(row.name) ?? text(row.body) ?? text(row.planet);
if (!name) continue;
bodies.push({
name,
mansion: text(row.mansion) ?? text(row.xiu) ?? "—",
mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0,
palace: text(row.palace) ?? text(row.branch) ?? "—",
dignity: text(row.dignity) ?? text(row.status) ?? "未闭合",
dignityClosed: false,
});
}
} else {
const asRecord = record(bodiesRaw);
if (asRecord) {
for (const [name, value] of Object.entries(asRecord)) {
const row = record(value) ?? {};
bodies.push({
name,
mansion: text(row.mansion) ?? text(row.xiu) ?? "—",
mansionDegree: finite(row.mansion_degree) ?? finite(row.siderealLon) ?? finite(row.degree) ?? 0,
palace: text(row.palace) ?? text(row.branch) ?? "—",
dignity: text(row.dignity) ?? "未闭合",
dignityClosed: false,
});
}
}
}
if (palaces.length === 0 || bodies.length === 0) return unavailableQizheng("payload_incomplete");
return {
status: "ok",
coordinateSystem: text(raw.coordinate_system) ?? "qizheng_mansion_degrees_from_jiao",
ketuMode: text(raw.ketu_mode) ?? text(raw.ketuMode) ?? "unspecified",
boundary: COORDINATE_BOUNDARY.qizheng,
palaces,
bodies,
};
}
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
const { chart, varga, chara, western, qizheng, profile, asOf } = bundle;
const planets = asPlanetMap(chart.planets);
const moon = planets.Moon ?? {};
const ascendant = record(chart.ascendant);
const birth = record(chart.birth) ?? {};
const ayanamsa = text(birth.ayanamsa_name) ?? profile.ayanamsa ?? "raman";
const ayanamsaDisplay = text(birth.ayanamsa_display) ?? ayanamsa;
const nodeMode = text(birth.node_mode) === "true" ? "true" : "mean";
const vargas = buildVargas(chart, varga);
if (vargas.length === 0) throw new Error("vedic_chart_unavailable");
const vimshottari = vimshottariTrack(chart, asOf);
const charaMapped = charaTrack(chara, profile.date, asOf);
const placeLabel = profile.placeLabel?.trim()
|| formatCoord(profile.latitude, profile.longitude);
const view = {
status: "ok" as const,
billed: false as const,
profile: {
name: profile.name?.trim() || "未命名",
date: profile.date,
time: profile.time.slice(0, 5),
placeLabel,
latitude: profile.latitude,
longitude: profile.longitude,
timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`,
ayanamsa,
ayanamsaDisplay,
nodeMode,
nodeModeLabel: nodeModeLabel(nodeMode),
birthTimeStatus: profile.birthTimeStatus ?? "reported",
birthTimeStatusLabel: birthTimeStatusLabel(profile.birthTimeStatus ?? "reported"),
engineName: "Swiss Ephemeris(本站)",
moonNakshatra: text(moon.nakshatra) ?? "—",
moonPada: Math.min(4, Math.max(1, Math.trunc(finite(moon.nakshatra_pada) ?? 1))),
ascendantSign: text(ascendant?.sign) ?? "—",
ascendantSignLabel: signZh(text(ascendant?.sign) ?? "—"),
currentDashaLabel: vimshottari.currentLabel,
},
vedic: {
coordinateSystem: "sidereal" as const,
boundary: COORDINATE_BOUNDARY.vedic,
vargas,
planets: planetRows(chart, varga),
symbolBoundary: SYMBOL_BOUNDARY,
},
dasha: {
vimshottari,
chara: charaMapped,
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
},
western: mapWestern(western, ayanamsaDisplay),
qizheng: mapQizheng(qizheng),
};
return chartViewOkSchema.parse(view);
}
export function engineChartHasPlanets(chart: Record<string, unknown>): boolean {
return PLANET_ORDER.some((name) => Boolean(asPlanetMap(chart.planets)[name]));
}
+107
View File
@@ -0,0 +1,107 @@
import "server-only";
import { consumeRequestRateLimit } from "./request-rate-limit.ts";
import {
ACCOUNT_BIRTH_SELECT,
globalBirthProfileFromAccountRow,
} from "./server-owned-birth-profile.ts";
import { createServerSupabaseClient } from "./supabase/server.ts";
import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts";
import type { ChartViewProfileInput } from "./chart-view-mapper.ts";
import type { ChartViewResponse } from "./chart-view-contract.ts";
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const engineTimeoutMs = 45_000;
function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function finite(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
async function postEngine(path: string, body: Record<string, unknown>): Promise<Record<string, unknown> | null> {
try {
const response = await fetch(`${jyotishApiBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: AbortSignal.timeout(engineTimeoutMs),
});
if (!response.ok) return null;
const payload = await response.json().catch(() => null);
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
return payload as Record<string, unknown>;
} catch {
return null;
}
}
function profileFromRow(row: unknown): ChartViewProfileInput | null {
const profile = globalBirthProfileFromAccountRow(row);
const record = row && typeof row === "object" && !Array.isArray(row)
? row as Record<string, unknown>
: {};
if (!profile.date || !profile.time) return null;
const latitude = finite(profile.latitude);
const longitude = finite(profile.longitude);
const timezoneOffset = finite(profile.timezoneOffset);
if (latitude === undefined || longitude === undefined || timezoneOffset === undefined) return null;
return {
name: profile.name,
date: profile.date,
time: profile.time,
placeLabel: text(record.birth_place_label),
latitude,
longitude,
timezoneOffset,
timezoneId: profile.timezoneId,
ayanamsa: typeof profile.ayanamsa === "string" ? profile.ayanamsa : undefined,
birthTimeStatus: profile.birthTimeStatus,
};
}
export async function loadChartView(now = new Date()): Promise<{
httpStatus: number;
body: ChartViewResponse;
}> {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return assembleChartView({
userId: null,
profile: null,
postEngine: postEngine as ChartViewEnginePost,
asOf: now.toISOString().slice(0, 10),
});
}
const limited = consumeRequestRateLimit({
key: `chartView:${user.id}`,
limit: 20,
windowMs: 60_000,
});
const { data: row } = await supabase
.from("profiles")
.select(ACCOUNT_BIRTH_SELECT)
.eq("id", user.id)
.maybeSingle();
return assembleChartView({
userId: user.id,
profile: profileFromRow(row),
postEngine,
asOf: now.toISOString().slice(0, 10),
rateLimited: !limited.ok,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { type PlanetName, type SignName } from "./chart-view-labels.ts";
/** Traditional keyword lines. Not a personal reading. */
const SYMBOLS: Partial<Record<PlanetName, Partial<Record<SignName, string>>>> = {
Sun: {
Aries: "词条:意志、起头、公开露面",
Taurus: "词条:稳定、资源、感官享受",
Gemini: "词条:说话、学习、短途往来",
Cancer: "词条:家、照顾、情绪根基",
Leo: "词条:表达、被看见、创造性权威",
Virgo: "词条:技艺、条理、日常工作",
Libra: "词条:伙伴、审美、公开关系",
Scorpio: "词条:深度、共享资源、转化",
Sagittarius: "词条:远方、信念、教学",
Capricorn: "词条:责任、结构、社会位置",
Aquarius: "词条:群体、革新、疏离的理想",
Pisces: "词条:退隐、想象、无边界的同情",
},
Moon: {
Aries: "词条:情绪来得快、要立刻反应",
Taurus: "词条:安稳、身体舒适、持有",
Gemini: "词条:心思活、要说话才安",
Cancer: "词条:护家、记忆、潮汐般的心情",
Leo: "词条:要被看见才踏实",
Virgo: "词条:用做事来安情绪",
Libra: "词条:要有人在、要公平",
Scorpio: "词条:感受深、不轻易摊开",
Sagittarius: "词条:要空间、要意义",
Capricorn: "词条:克制、责任压过心情",
Aquarius: "词条:疏离、从群体里找归属",
Pisces: "词条:渗透、梦、边界薄",
},
};
export function traditionalSymbol(planet: string, sign: string): string | null {
const byPlanet = SYMBOLS[planet as PlanetName];
if (!byPlanet) {
return "词条:传统象征按星体与星座检索,这一格只列事实位置。";
}
return byPlanet[sign as SignName] ?? "词条:传统象征按星体与星座检索,这一格只列事实位置。";
}