feat(chart): add a read-only natal chart page
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:
@@ -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 });
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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", "这张盘这会儿算不出来。资料还在,过一会儿再打开。"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 Dasha(kn_rao 变体)",
|
||||
method: text(chara?.method) === "Chara Dasha (kn_rao)"
|
||||
? "Chara Dasha(kn_rao 变体)"
|
||||
: (text(chara?.method) ?? "Chara Dasha(kn_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]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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] ?? "词条:传统象征按星体与星座检索,这一格只列事实位置。";
|
||||
}
|
||||
Reference in New Issue
Block a user