feat(chart): Moon chart from D1 and transit chart for today in the natal houses (T4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:33:30 +08:00
co-authored by Claude Opus 5.5
parent 2071dc7c18
commit 3f6bcb081a
17 changed files with 552 additions and 74 deletions
+1 -17
View File
@@ -13,6 +13,7 @@ import {
import { dailyProfilePayload } from "@/lib/global-birth-payloads";
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
import { globalBirthProfileFromAccountRow } from "@/lib/server-owned-birth-profile";
import { transitChartBody } from "@/lib/transit-chart-body";
import { ConsultationSubjectError, loadSubjectBirth, subjectIdFromQuery } from "@/lib/subject-birth";
import { createServerSupabaseClient } from "@/lib/supabase/server";
@@ -215,20 +216,3 @@ export async function GET(request: Request) {
}
return NextResponse.json(parsed.data);
}
function transitChartBody(birth: BirthPayload, date: string): Record<string, unknown> {
const [year, month, day] = date.split("-").map(Number);
return {
year,
month,
day,
hour: 12,
minute: 0,
lat: birth.lat,
lon: birth.lon,
tz: birth.tz,
ayanamsa: birth.ayanamsa,
node_mode: birth.node_mode,
skip_vedastro_main_entry_overview: true,
};
}
+6 -2
View File
@@ -5087,7 +5087,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
}
@media print { .chart-page-type-sheet { display: none !important; } }
.chart-page-precision-hint { margin: 0 0 var(--space-2); }
.chart-page-precision-hint,
.chart-page-table-note { margin: 0 0 var(--space-2); }
/* A note + table pair is one grid item, so the table keeps its own scroll box. */
.chart-page-varga-table-block { min-width: 0; }
.chart-page-waiting-plot {
width: min(100%, 400px);
min-width: 0;
@@ -5485,7 +5488,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
}
/* Chart and parameters sit side by side; the planet table is a full-width
row under both, because its columns need the whole measure. */
.chart-page-planet-table-wrap { grid-column: 1 / -1; }
.chart-page-planet-table-wrap,
.chart-page-varga-table-block { grid-column: 1 / -1; }
.chart-page-basics-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@@ -37,7 +37,8 @@ export function ChartPageRoute() {
* the natal packet that is already on the page, so they request nothing.
*/
function chartTypeLayer(id: ChartTypeId): ChartViewLayer | null {
if (id === "D1" || id === "MOON" || id === "BHAVA" || id === "TRANSIT") return null;
if (id === "D1" || id === "MOON" || id === "BHAVA") return null;
if (id === "TRANSIT") return "transit";
return "varga";
}
@@ -86,6 +87,8 @@ export function ChartPageView({
if (layer) onNeedLayer?.(layer);
}
const vedicLayer = chartTypeLayer(vargaId);
const birthline = view != null && view.status === "ok"
? `${view.profile.name} · ${view.profile.date} ${view.profile.time} · ${view.profile.placeLabel}`
: null;
@@ -127,8 +130,8 @@ export function ChartPageView({
view={view}
vargaId={vargaId}
onSelectVarga={selectVarga}
pending={pendingLayers?.has("varga") ?? false}
failure={layerFailures?.get("varga")}
pending={vedicLayer ? pendingLayers?.has(vedicLayer) ?? false : false}
failure={vedicLayer ? layerFailures?.get(vedicLayer) : undefined}
/>
) : null}
{tab === "basics" ? <ChartBasicsTab view={view} /> : null}
@@ -1,14 +1,18 @@
"use client";
import type { ReactNode } from "react";
import { VedicChartSvg } from "@/components/personal-report/vedic-chart-svg";
import type {
ChartViewFailureStatus,
ChartViewAscendantRow,
ChartViewOk,
ChartViewPlanetRow,
ChartViewTransitOk,
ChartViewVarga,
ChartViewVargaAscendantRow,
ChartViewVargaRow,
NorthIndianChart,
} from "@/lib/chart-view-contract";
import { BIRTH_TIME_SENSITIVE_VARGAS, CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels";
import { ChartTypePicker } from "./chart-type-picker";
@@ -71,8 +75,11 @@ function PlanetRow({ row }: { row: ChartViewPlanetRow }) {
);
}
/** The ascendant is a chart point: the engine returns no nakshatra for it. */
function AscendantRow({ row }: { row: ChartViewAscendantRow }) {
/**
* The ascendant is a chart point: the engine returns no nakshatra for it.
* `house` is 1 except on the Moon chart, which counts from the Moon.
*/
function AscendantRow({ row, house = row.house }: { row: ChartViewAscendantRow; house?: number }) {
return (
<tr>
<th scope="row">
@@ -80,7 +87,7 @@ function AscendantRow({ row }: { row: ChartViewAscendantRow }) {
</th>
<td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td>
<td className="chart-page-planet-mid">顺</td>
<td className="chart-page-planet-mid">{row.house}宫</td>
<td className="chart-page-planet-mid">{house}宫</td>
<td>{row.nakshatra ?? "—"}</td>
<td className="chart-page-planet-mid">
{row.nakshatraLord ? <BodyGlyph name={row.nakshatraLord} /> : "—"}
@@ -95,7 +102,12 @@ function AscendantRow({ row }: { row: ChartViewAscendantRow }) {
* The natal eight-column table. Everything the chart stopped drawing lands
* here: the chart carries symbols, this table carries the numbers behind them.
*/
function NatalTable({ view, caption }: { view: ChartViewOk; caption: string }) {
function NatalTable({ planets, ascendant, ascendantHouse, caption }: {
planets: readonly ChartViewPlanetRow[];
ascendant: ChartViewAscendantRow | null;
ascendantHouse?: number;
caption: string;
}) {
return (
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
@@ -113,8 +125,8 @@ function NatalTable({ view, caption }: { view: ChartViewOk; caption: string }) {
</tr>
</thead>
<tbody>
{view.vedic.ascendant && <AscendantRow row={view.vedic.ascendant} />}
{view.vedic.planets.map((planet) => (
{ascendant && <AscendantRow row={ascendant} house={ascendantHouse} />}
{planets.map((planet) => (
<PlanetRow key={planet.name} row={planet} />
))}
</tbody>
@@ -189,6 +201,122 @@ function PrecisionHint({ view, id }: { view: ChartViewOk; id: string }) {
return <p className="chart-page-note chart-page-precision-hint">{CHART_VIEW_COPY.precisionHint}</p>;
}
function TransitTable({ transit }: { transit: ChartViewTransitOk }) {
return (
<div className="chart-page-planet-table-wrap">
<table className="chart-page-planet-table">
<caption>行运 · {transit.date} {transit.time}({transit.timezoneLabel})</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>
{transit.rows.map((row) => {
const key = planetGlyphKey(row.name);
return (
<tr key={row.name}>
<th scope="row">
<PlanetCell name={row.name} label={row.label} abbr={key ? PLANET_ABBR[key] : "—"} />
</th>
<td className="chart-page-planet-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}</td>
<td className="chart-page-planet-mid">{row.retrograde ? "逆" : "顺"}</td>
<td className="chart-page-planet-mid">{row.natalHouse}宫</td>
<td className="chart-page-planet-mid">{row.natalPlanetHouse === null ? "—" : `${row.natalPlanetHouse}宫`}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/** Chart, parameter table and boundary line, then the chart type's own table. */
function VedicLayout({ view, chart, ariaLabel, ascendantHouse, children }: {
view: ChartViewOk;
chart: NorthIndianChart;
ariaLabel: string;
ascendantHouse?: number;
children: ReactNode;
}) {
return (
<div className="chart-page-vedic-layout">
<div className="chart-page-vedic-stage">
<VedicChartSvg chart={chart} ariaLabel={ariaLabel} ascendantHouse={ascendantHouse} />
</div>
<div className="chart-page-params">
<ChartParamTable view={view} />
{/* The accuracy boundary sits directly under the parameters it
qualifies. Server copy, reproduced verbatim — it says what this
chart can and cannot be read for. */}
<p className="chart-page-boundary">{view.vedic.boundary}</p>
</div>
{children}
</div>
);
}
function LayerWaiting({ pending, failure, copy }: { pending: boolean; failure?: ChartViewFailureStatus; copy: string }) {
return !pending && failure
? <p className="chart-page-note">{chartLayerFailureCopy(failure)}</p>
: <ChartPageSkeleton copy={copy} />;
}
function ChartTypeBody({ view, vargaId, pending, failure }: {
view: ChartViewOk;
vargaId: string;
pending: boolean;
failure?: ChartViewFailureStatus;
}) {
if (vargaId === "MOON") {
const moon = view.vedic.moon;
if (!moon) return <p className="chart-page-note">{CHART_VIEW_COPY.unavailable}</p>;
return (
<VedicLayout view={view} chart={moon.chart} ascendantHouse={moon.ascendantHouse} ariaLabel="月亮盘 北印度盘 以月亮所在星座为第 1 宫">
<NatalTable
planets={moon.planets}
ascendant={view.vedic.ascendant}
ascendantHouse={moon.ascendantHouse}
caption={`月亮盘 · 以月亮所在星座(${moon.moonSignLabel})为第 1 宫`}
/>
</VedicLayout>
);
}
if (vargaId === "TRANSIT") {
const transit = view.transit;
if (transit.status !== "ok") return <LayerWaiting pending={pending} failure={failure} copy={CHART_VIEW_COPY.waitingChart} />;
return (
<VedicLayout view={view} chart={transit.chart} ariaLabel={`行运 北印度盘 ${transit.date} 以本命上升为第 1 宫`}>
<div className="chart-page-varga-table-block">
<p className="chart-page-note chart-page-table-note">{CHART_VIEW_COPY.transitNote}</p>
<TransitTable transit={transit} />
</div>
</VedicLayout>
);
}
const selected = view.vedic.vargas.find((item) => item.id === vargaId);
const natal = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0];
const varga = selected ?? (vargaId === "D1" ? natal : undefined);
if (!varga) return <LayerWaiting pending={pending} failure={failure} copy={CHART_VIEW_COPY.waitingVarga} />;
return (
<VedicLayout view={view} chart={varga.chart} ariaLabel={`${varga.id} 北印度盘 ${varga.meaning}`}>
{varga.id === "D1" || !varga.rows ? (
<NatalTable planets={view.vedic.planets} ascendant={view.vedic.ascendant} caption={`${varga.id} · ${varga.meaning}`} />
) : (
<div className="chart-page-varga-table-block">
<PrecisionHint view={view} id={varga.id} />
<VargaTable varga={varga} />
</div>
)}
</VedicLayout>
);
}
export function ChartVedicTab({
view,
vargaId,
@@ -203,39 +331,10 @@ export function ChartVedicTab({
failure?: ChartViewFailureStatus;
onSelectVarga: (id: ChartTypeId) => void;
}) {
const selected = view.vedic.vargas.find((item) => item.id === vargaId);
const natal = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0];
const varga = selected ?? (vargaId === "D1" ? natal : undefined);
const waiting = vargaId !== "D1" && !selected;
return (
<div className="chart-page-vedic">
<ChartTypePicker current={vargaId} onSelect={onSelectVarga} />
{waiting || !varga ? (
!pending && failure ? <p className="chart-page-note">{chartLayerFailureCopy(failure)}</p>
: <ChartPageSkeleton copy={CHART_VIEW_COPY.waitingVarga} />
) : (
<div className="chart-page-vedic-layout">
<div className="chart-page-vedic-stage">
<VedicChartSvg chart={varga.chart} ariaLabel={`${varga.id} 北印度盘 ${varga.meaning}`} />
</div>
<div className="chart-page-params">
<ChartParamTable view={view} />
{/* The accuracy boundary sits directly under the parameters it
qualifies. Server copy, reproduced verbatim — it says what this
chart can and cannot be read for. */}
<p className="chart-page-boundary">{view.vedic.boundary}</p>
</div>
{varga.id === "D1" || !varga.rows ? (
<NatalTable view={view} caption={`${varga.id} · ${varga.meaning}`} />
) : (
<div className="chart-page-varga-table-block">
<PrecisionHint view={view} id={varga.id} />
<VargaTable varga={varga} />
</div>
)}
</div>
)}
<ChartTypeBody view={view} vargaId={vargaId} pending={pending} failure={failure} />
</div>
);
}
@@ -73,6 +73,12 @@ interface VedicChartSvgProps {
chart: NorthIndianChartModel;
ariaLabel: string;
highlightHouseNumbers?: ReadonlySet<number>;
/**
* House that holds the natal ascendant. 1 for every chart counted from the
* ascendant (the default); the chart page's Moon chart counts from the Moon,
* so its lagna mark sits wherever the natal ascendant falls from there.
*/
ascendantHouse?: number;
}
type ChartToken =
@@ -132,9 +138,9 @@ function houseTokens(
* is drawn from geometry rather than from data. An explicit ascendant occupant
* (the report bundle can carry one) collapses into the same mark.
*/
function withAscendantMark(houseNumber: number, tokens: ChartToken[]): ChartToken[] {
function withAscendantMark(houseNumber: number, tokens: ChartToken[], ascendantHouse = 1): ChartToken[] {
const rest = tokens.filter((token) => token.kind !== "ascendant");
return houseNumber === 1 ? [{ kind: "ascendant" }, ...rest] : rest;
return houseNumber === ascendantHouse ? [{ kind: "ascendant" }, ...rest] : rest;
}
/**
@@ -279,7 +285,7 @@ export function VedicChartSvg(props: VedicChartSvgProps) {
}
/** Shared, hook-free drawing for the reader and offline Markdown export. */
export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId }: VedicChartSvgProps & { chartId: string }) {
export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highlightHouseNumbers, skeleton = false, chartId, ascendantHouse = 1 }: VedicChartSvgProps & { chartId: string }) {
const chart = skeleton ? EMPTY_NORTH_INDIAN_CHART : suppliedChart;
const retrograde = retrogradeKeys(chart);
const tokensByHouse = new Map<number, ChartToken[]>();
@@ -291,7 +297,9 @@ export function VedicChartSvgDrawing({ chart: suppliedChart, ariaLabel, highligh
const hasPlanets = [...tokensByHouse.values()]
.some((tokens) => tokens.some((token) => token.kind === "planet"));
if (hasPlanets) {
tokensByHouse.set(1, withAscendantMark(1, tokensByHouse.get(1) ?? []));
// An explicit ascendant occupant anywhere collapses into the one mark.
if (ascendantHouse !== 1) tokensByHouse.set(1, withAscendantMark(1, tokensByHouse.get(1) ?? [], ascendantHouse));
tokensByHouse.set(ascendantHouse, withAscendantMark(ascendantHouse, tokensByHouse.get(ascendantHouse) ?? [], ascendantHouse));
}
const viewBoxHeight = CHART_SIZE + (hasPlanets ? LEGEND_BAND_HEIGHT : 0);
+1
View File
@@ -48,6 +48,7 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie
...(layer === "varga" ? { vedic: incoming.vedic } : {}),
...(layer === "chara" ? { dasha: incoming.dasha } : {}),
...(layer === "western" ? { western: incoming.western } : {}),
...(layer === "transit" ? { transit: incoming.transit } : {}),
};
}
+41
View File
@@ -107,6 +107,42 @@ export const chartViewVargaSchema = z.object({
rows: z.array(chartViewVargaRowSchema).optional(),
});
/**
* The Moon chart (Chandra lagna): the D1 counted from the Moon's sign. The
* rows are the natal rows with only the house recounted; the natal ascendant
* sits in `ascendantHouse`.
*/
export const chartViewMoonChartSchema = z.object({
moonSign: z.string().min(1),
moonSignLabel: z.string().min(1),
ascendantHouse: z.number().int().min(1).max(12),
chart: northIndianChartSchema,
planets: z.array(chartViewPlanetRowSchema).min(1),
});
/**
* Today's sky placed in the natal houses (natal ascendant = house 1). Same
* ayanamsa and node mode as the natal chart; `date` / `time` / `timezoneLabel`
* say which moment was computed.
*/
export const chartViewTransitOkSchema = z.object({
status: z.literal("ok"),
date: isoDate,
time: z.string().regex(/^\d{2}:\d{2}$/),
timezoneLabel: z.string().min(1),
chart: northIndianChartSchema,
rows: z.array(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(),
retrograde: z.boolean(),
natalHouse: z.number().int().min(1).max(12),
natalPlanetHouse: z.number().int().min(1).max(12).nullable(),
})).min(1),
});
export const chartViewPeriodSchema = z.object({
lord: z.string().min(1),
label: z.string().min(1),
@@ -212,6 +248,7 @@ export const chartViewOkSchema = z.object({
varga: chartViewFailureStatusSchema.optional(),
chara: chartViewFailureStatusSchema.optional(),
western: chartViewFailureStatusSchema.optional(),
transit: chartViewFailureStatusSchema.optional(),
}).optional(),
status: z.literal("ok"),
billed: z.literal(false),
@@ -222,6 +259,7 @@ export const chartViewOkSchema = z.object({
vargas: z.array(chartViewVargaSchema).min(1),
ascendant: chartViewAscendantRowSchema.nullable(),
planets: z.array(chartViewPlanetRowSchema).min(1),
moon: chartViewMoonChartSchema.nullable(),
}),
dasha: z.object({
// The day the current flags were computed against. The dasha tab uses it
@@ -232,6 +270,7 @@ export const chartViewOkSchema = z.object({
dualTrackNote: z.string().min(1),
}),
western: z.union([chartViewWesternOkSchema, chartViewUnavailableLayerSchema]),
transit: z.union([chartViewTransitOkSchema, chartViewUnavailableLayerSchema]),
accountId: z.string().min(1).optional(),
profileFingerprint: z.string().min(1).optional(),
subjectId: z.string().min(1).optional(),
@@ -258,6 +297,8 @@ 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 ChartViewTransitOk = z.infer<typeof chartViewTransitOkSchema>;
export type ChartViewMoonChart = z.infer<typeof chartViewMoonChartSchema>;
export type ChartViewVarga = z.infer<typeof chartViewVargaSchema>;
export type ChartViewVargaRow = z.infer<typeof chartViewVargaRowSchema>;
export type ChartViewVargaAscendantRow = z.infer<typeof chartViewVargaAscendantRowSchema>;
+10 -1
View File
@@ -2,7 +2,7 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000;
export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000;
const ENGINE_CACHE_MAX_ENTRIES = 200;
export const CHART_VIEW_LAYERS = ["varga", "chara", "western"] as const;
export const CHART_VIEW_LAYERS = ["varga", "chara", "western", "transit"] as const;
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
export type EngineCallResult =
@@ -64,6 +64,15 @@ export function chartViewEngineCacheKey(input: {
].join("\u001f");
}
/**
* The path slot of the cache key. A call whose body is not the birth moment
* (the transit chart posts `/api/chart` for a date) carries a scope, so it can
* never be answered with the cached natal chart or with another day's sky.
*/
export function chartViewEngineCachePath(path: string, cacheScope?: string): string {
return cacheScope ? `${path}@${cacheScope}` : path;
}
export function readChartViewEngineCache(key: string, now = Date.now()): Record<string, unknown> | null {
const entry = engineCache().get(key);
if (!entry) return null;
+1
View File
@@ -191,6 +191,7 @@ export const CHART_VIEW_COPY = {
chartTypeSheetTitle: "星盘类型",
chartTypeMenu: "全部盘型",
chartTypeSheetClose: "关闭星盘类型",
transitNote: "以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。",
precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。",
unauthenticated: "请先登录后再看星盘。",
incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。",
+27 -2
View File
@@ -14,15 +14,23 @@ import {
} from "./chart-view-engine.ts";
import { chartViewFailureResponse } from "./chart-view-failure.ts";
import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "./chart-view-labels.ts";
import { transitChartBody } from "./transit-chart-body.ts";
import {
buildChartView,
engineChartHasPlanets,
type ChartViewProfileInput,
} from "./chart-view-mapper.ts";
/**
* `cacheScope` separates calls to one path whose body is not the birth moment
* (the transit chart posts `/api/chart` for today): the server cache is keyed
* by birth and path, so without it the transit call would be served the natal
* chart.
*/
export type ChartViewEnginePost = (
path: string,
body: Record<string, unknown>,
cacheScope?: string,
) => Promise<EngineCallResult>;
export type ChartViewLoadInput = {
@@ -90,8 +98,9 @@ async function callEngine(
postEngine: ChartViewEnginePost,
path: string,
body: Record<string, unknown>,
cacheScope?: string,
): Promise<EngineCallResult> {
const result = await postEngine(path, body);
const result = await postEngine(path, body, cacheScope);
if (result.status !== "ok") logEngineCallFailure(result);
return result;
}
@@ -157,7 +166,8 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
if (result.status !== "ok") layerFailures[layer] = engineFailureReason(result.status);
return enginePayload(result);
};
const [varga, chara, western] = await Promise.all([
const transitDate = input.asOf.slice(0, 10);
const [varga, chara, western, transit] = await Promise.all([
layers.has("varga")
? callEngine(input.postEngine, "/api/varga_full", {
...followUp,
@@ -174,6 +184,20 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
layers.has("western")
? callEngine(input.postEngine, "/api/western", payload).then(layerPayload("western"))
: Promise.resolve(null),
layers.has("transit")
? callEngine(
input.postEngine,
"/api/chart",
transitChartBody({
lat: profile.latitude,
lon: profile.longitude,
tz: profile.timezoneOffset,
ayanamsa: String(payload.ayanamsa),
node_mode: String(payload.node_mode),
}, transitDate),
`transit:${transitDate}`,
).then(layerPayload("transit"))
: Promise.resolve(null),
]);
try {
@@ -182,6 +206,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
varga,
chara,
western,
transit,
profile,
asOf: input.asOf,
});
+91 -1
View File
@@ -5,6 +5,7 @@ import {
import {
ANTARDASHA_DERIVED_NOTE,
CHARA_ANTARDASHA_DATE_NOTE,
CHART_VIEW_COPY,
COORDINATE_BOUNDARY,
DASHA_DUAL_TRACK_NOTE,
PLANET_ORDER,
@@ -23,9 +24,13 @@ import {
} from "./chart-view-labels.ts";
import { traditionalSymbol } from "./chart-view-symbols.ts";
import { vargaDignity } from "./chart-view-varga.ts";
import { transitsFromEngine } from "./ephemeris-view.ts";
import { TRANSIT_LOCAL_TIME } from "./transit-chart-body.ts";
import {
chartViewOkSchema,
type ChartViewMoonChart,
type ChartViewOk,
type ChartViewTransitOk,
type ChartViewUnavailableLayer,
type ChartViewVarga,
type ChartViewVargaAscendantRow,
@@ -52,6 +57,7 @@ export type ChartViewEngineBundle = {
varga: Record<string, unknown> | null;
chara: Record<string, unknown> | null;
western: Record<string, unknown> | null;
transit?: Record<string, unknown> | null;
profile: ChartViewProfileInput;
asOf: string;
};
@@ -393,6 +399,88 @@ function ascendantRow(chart: Record<string, unknown>) {
};
}
/**
* The Moon chart: the natal block with the Moon's sign as house 1. Every sign,
* degree and direction is the D1's; only the house count changes.
*/
function moonChart(chart: Record<string, unknown>, vargaPacket: Record<string, unknown> | null): ChartViewMoonChart | null {
const block = natalBlock(chart);
const moon = asPlanetMap(chart.planets).Moon;
const moonSign = text(moon?.sign);
const natalAsc = text(record(chart.ascendant)?.sign);
if (!block || !moon || !moonSign || !isSignName(moonSign) || !natalAsc || !isSignName(natalAsc)) return null;
const moonBlock: ReportChartBlock = {
...block,
id: "MOON",
title: "月亮盘",
ascendant: { sign: moonSign, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(moon))) },
};
return {
moonSign,
moonSignLabel: signZh(moonSign),
ascendantHouse: houseFromSigns(natalAsc, moonSign),
chart: toNorthIndianChart(moonBlock),
planets: planetRows(chart, vargaPacket).map((row) => ({ ...row, house: houseFromSigns(row.sign, moonSign) })),
};
}
function unavailableTransit(reason: string): ChartViewUnavailableLayer {
return { status: "unavailable", reason, copy: CHART_VIEW_COPY.unavailable };
}
/**
* Today's positions placed in the natal houses. The rows are the ephemeris
* page's own projection (`transitsFromEngine`), so the two pages agree.
*/
function mapTransit(
raw: Record<string, unknown> | null | undefined,
chart: Record<string, unknown>,
profile: ChartViewProfileInput,
asOf: string,
): ChartViewTransitOk | ChartViewUnavailableLayer {
if (!raw) return unavailableTransit("not_requested");
const natalAsc = text(record(chart.ascendant)?.sign);
if (!natalAsc || !isSignName(natalAsc)) return unavailableTransit("natal_ascendant_missing");
const { bodies } = transitsFromEngine({ transitChart: raw, natalChart: chart });
const natalPlanets = asPlanetMap(chart.planets);
const rows = bodies.flatMap((body) => {
if (body.natalHouse === null || !isSignName(body.sign)) return [];
const natal = natalPlanets[body.planet];
return [{
name: body.planet,
label: planetZh(body.planet),
sign: body.sign,
signLabel: signZh(body.sign),
degreeInSign: body.degreeInSign,
retrograde: body.retrograde,
natalHouse: body.natalHouse,
natalPlanetHouse: natal ? houseFromPlanet(natal, natalAsc) : null,
}];
});
if (rows.length === 0) return unavailableTransit("payload_incomplete");
const block: ReportChartBlock = {
version: 1,
id: "D1",
title: "行运",
layout: "north",
ascendant: { sign: natalAsc, degree: Math.min(29.999, Math.max(0, planetDegreeInSign(record(chart.ascendant) ?? {}))) },
planets: rows.map((row) => ({
name: row.name as ReportChartBlock["planets"][number]["name"],
sign: row.sign,
degree: Math.min(29.999, Math.max(0, row.degreeInSign)),
retrograde: row.retrograde,
})),
};
return {
status: "ok",
date: isoDay(asOf),
time: TRANSIT_LOCAL_TIME,
timezoneLabel: profile.timezoneId || `UTC${profile.timezoneOffset >= 0 ? "+" : ""}${profile.timezoneOffset}`,
chart: toNorthIndianChart(block),
rows,
};
}
function vimshottariTrack(chart: Record<string, unknown>, asOf: string) {
const dasha = record(chart.dasha) ?? {};
const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : [];
@@ -611,7 +699,7 @@ function mapWestern(raw: Record<string, unknown> | null, ayanamsaDisplay: string
}
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
const { chart, varga, chara, western, profile, asOf } = bundle;
const { chart, varga, chara, western, transit, profile, asOf } = bundle;
const planets = asPlanetMap(chart.planets);
const moon = planets.Moon ?? {};
const ascendant = record(chart.ascendant);
@@ -656,6 +744,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
vargas,
ascendant: ascendantRow(chart),
planets: planetRows(chart, varga),
moon: moonChart(chart, varga),
},
dasha: {
asOf: isoDay(asOf),
@@ -664,6 +753,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
dualTrackNote: DASHA_DUAL_TRACK_NOTE,
},
western: mapWestern(western, ayanamsaDisplay),
transit: mapTransit(transit, chart, profile, asOf),
};
return chartViewOkSchema.parse(view);
}
+3 -2
View File
@@ -14,6 +14,7 @@ import type { ChartBirthFailureReason } from "./chart-birth-truth.ts";
import {
CHART_VIEW_ENGINE_TIMEOUT_MS,
chartViewEngineCacheKey,
chartViewEngineCachePath,
engineCallFromHttp,
engineCallFromThrown,
readChartViewEngineCache,
@@ -69,7 +70,7 @@ async function postEngine(path: string, body: Record<string, unknown>): Promise<
function cachedPostEngine(userId: string, subjectId: string, profile: ChartViewProfileInput): ChartViewEnginePost {
const ayanamsa = resolveAyanamsa({ ayanamsa: profile.ayanamsa });
const nodeMode = "mean";
return async (path, body) => {
return async (path, body, cacheScope) => {
const key = chartViewEngineCacheKey({
userId,
subjectId,
@@ -80,7 +81,7 @@ function cachedPostEngine(userId: string, subjectId: string, profile: ChartViewP
timezoneOffset: profile.timezoneOffset,
ayanamsa,
nodeMode,
path,
path: chartViewEngineCachePath(path, cacheScope),
});
const hit = readChartViewEngineCache(key);
if (hit) return { status: "ok", payload: hit };
+1
View File
@@ -214,6 +214,7 @@ async function loadChartSnapshot(started: number, signal: AbortSignal): Promise<
...(prior.vedic.vargas.length > 1 ? { vedic: prior.vedic } : {}),
dasha: { ...snapshot.view.dasha, ...(prior.dasha.chara.periods.length ? { chara: prior.dasha.chara } : {}) },
...(prior.western.status === "ok" ? { western: prior.western } : {}),
...(prior.transit.status === "ok" ? { transit: prior.transit } : {}),
} };
}
return rememberChartSnapshot(snapshot);
+34
View File
@@ -0,0 +1,34 @@
/** The birth frame a transit chart borrows: place, clock offset and zodiac settings. */
export type TransitBirthFrame = {
lat: number;
lon: number;
tz: number;
ayanamsa: string;
node_mode: string;
};
/**
* `/api/chart` body for the sky at local noon of `date` (YYYY-MM-DD), in the
* birth place's clock and the birth chart's ayanamsa and node mode.
*
* Shared by the 星历 page's 当日行运 and the 星盘 page's 行运 chart, so both
* say the same thing about the same day.
*/
export const TRANSIT_LOCAL_TIME = "12:00";
export function transitChartBody(birth: TransitBirthFrame, date: string): Record<string, unknown> {
const [year, month, day] = date.split("-").map(Number);
return {
year,
month,
day,
hour: 12,
minute: 0,
lat: birth.lat,
lon: birth.lon,
tz: birth.tz,
ayanamsa: birth.ayanamsa,
node_mode: birth.node_mode,
skip_vedastro_main_entry_overview: true,
};
}
+153
View File
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import test from "node:test";
import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx";
import { VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
import { chartViewOkSchema, type ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { chartViewEngineCacheKey, chartViewEngineCachePath } from "../src/lib/chart-view-engine.ts";
import { CHART_VIEW_COPY, SIGN_ORDER } from "../src/lib/chart-view-labels.ts";
Object.assign(globalThis, { React });
// Real engine output only: the chart-page golden and the ephemeris page's
// `/api/chart` packet for 2026-09-15 noon (same engine route, same Raman mean).
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const transitPacket = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const serviceSource = readFileSync(new URL("../src/lib/chart-view-service.ts", import.meta.url), "utf8");
const profile = {
name: "示例",
date: "1990-06-15",
time: "12:00",
placeLabel: "北京",
latitude: 39.9042,
longitude: 116.4074,
timezoneOffset: 8,
timezoneId: "Asia/Shanghai",
ayanamsa: "raman",
birthTimeStatus: "confirmed",
};
type Call = { path: string; body: Record<string, unknown>; cacheScope?: string };
async function assemble(layers: ("transit" | "varga")[]): Promise<{ view: ChartViewOk; calls: Call[] }> {
const calls: Call[] = [];
const result = await assembleChartView({
userId: "user-1",
profile,
asOf: "2026-09-15",
layers,
postEngine: async (path, body, cacheScope) => {
calls.push({ path, body, cacheScope });
if (path === "/api/chart" && cacheScope?.startsWith("transit:")) return { status: "ok", payload: transitPacket };
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
},
});
assert.equal(result.body.status, "ok");
return { view: chartViewOkSchema.parse(result.body), calls };
}
function signIndex(sign: string): number {
return (SIGN_ORDER as readonly string[]).indexOf(sign);
}
/** House whose clip group holds the lagna mark. */
function ascendantMarkHouses(svg: string): number[] {
return svg.split('clip-path="url(#').slice(1).flatMap((chunk) => {
const house = Number(chunk.match(/-house-(\d+)\)/)?.[1]);
const body = chunk.split("</g></g>")[0] ?? "";
return body.includes("personal-report-chart-asc") ? [house] : [];
});
}
test("the Moon chart is the D1 counted from the Moon's sign, projected without a request", async () => {
const { view, calls } = await assemble([]);
assert.deepEqual(calls.map((call) => call.path), ["/api/chart"], "no extra engine call");
const moon = view.vedic.moon;
assert.ok(moon);
const natalMoonSign = golden.chart.planets.Moon.sign as string;
const natalAsc = golden.chart.ascendant.sign as string;
assert.equal(moon.moonSign, natalMoonSign);
assert.equal(moon.chart.houses[0]!.sign, natalMoonSign, "house 1 is the Moon's sign");
assert.equal(moon.ascendantHouse, ((signIndex(natalAsc) - signIndex(natalMoonSign) + 12) % 12) + 1);
for (const row of moon.planets) {
const natal = view.vedic.planets.find((item) => item.name === row.name)!;
assert.equal(row.sign, natal.sign, "signs are the D1's");
assert.equal(row.degreeInSign, natal.degreeInSign);
assert.equal(row.house, ((signIndex(row.sign) - signIndex(natalMoonSign) + 12) % 12) + 1);
}
assert.equal(moon.planets.find((row) => row.name === "Moon")?.house, 1);
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "MOON", onSelectVarga: () => {} }));
assert.match(markup, /<caption>月亮盘 · 以月亮所在星座([^)]+)为第 1 宫<\/caption>/);
assert.deepEqual(ascendantMarkHouses(markup), [moon.ascendantHouse], "the lagna mark sits in the natal ascendant's house");
});
test("a chart counted from the ascendant still draws the lagna mark in house 1 only", async () => {
const { view } = await assemble([]);
const svg = renderToStaticMarkup(React.createElement(VedicChartSvg, { chart: view.vedic.vargas[0]!.chart, ariaLabel: "D1" }));
assert.deepEqual(ascendantMarkHouses(svg), [1]);
});
test("the transit layer posts today's noon with the natal ayanamsa and node mode, under its own cache scope", async () => {
const { view, calls } = await assemble(["transit"]);
const natal = calls.find((call) => call.path === "/api/chart" && !call.cacheScope)!;
const transit = calls.find((call) => call.cacheScope?.startsWith("transit:"))!;
assert.ok(transit, "transit call made");
assert.equal(transit.path, "/api/chart");
assert.equal(transit.cacheScope, "transit:2026-09-15");
assert.deepEqual([transit.body.year, transit.body.month, transit.body.day, transit.body.hour, transit.body.minute], [2026, 9, 15, 12, 0]);
assert.equal(transit.body.ayanamsa, natal.body.ayanamsa);
assert.equal(transit.body.node_mode, natal.body.node_mode);
assert.equal(transit.body.tz, natal.body.tz);
assert.equal(transit.body.skip_vedastro_main_entry_overview, true);
assert.equal(view.transit.status, "ok");
if (view.transit.status !== "ok") throw new Error("expected transit ok");
assert.equal(view.transit.date, "2026-09-15");
assert.equal(view.transit.time, "12:00");
assert.equal(view.transit.timezoneLabel, "Asia/Shanghai");
const natalAsc = golden.chart.ascendant.sign as string;
assert.equal(view.transit.rows.length, 9);
for (const row of view.transit.rows) {
const engine = transitPacket.planets[row.name];
assert.equal(row.sign, engine.sign);
assert.equal(row.natalHouse, ((signIndex(row.sign) - signIndex(natalAsc) + 12) % 12) + 1);
assert.equal(row.natalPlanetHouse, golden.chart.planets[row.name].house);
}
assert.equal(view.transit.chart.houses[0]!.sign, natalAsc, "house 1 is the natal ascendant");
const markup = renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {} }));
assert.match(markup, /<caption>行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/);
for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`<th scope="col">${column}</th>`));
assert.ok(markup.includes(CHART_VIEW_COPY.transitNote));
});
test("the transit tab waits, then fails with the layer copy, and is never requested for D1", async () => {
const { view, calls } = await assemble([]);
assert.equal(calls.some((call) => call.cacheScope), false);
assert.equal(view.transit.status, "unavailable");
const render = (props: Record<string, unknown>) => renderToStaticMarkup(React.createElement(ChartVedicTab, { view, vargaId: "TRANSIT", onSelectVarga: () => {}, ...props }));
assert.match(render({ pending: true }), /data-skeleton="true"/);
assert.ok(render({ pending: true }).includes(CHART_VIEW_COPY.waitingChart));
const failed = render({ pending: false, failure: "engine_busy" });
assert.ok(failed.includes(CHART_VIEW_COPY.busy));
assert.doesNotMatch(failed, /data-skeleton/);
});
test("a scoped engine call can never share the natal chart's cache entry", () => {
const base = { userId: "u", subjectId: "s", date: "1990-06-15", time: "12:00", latitude: 1, longitude: 2, timezoneOffset: 8, ayanamsa: "raman", nodeMode: "mean" };
const natal = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart") });
const today = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart", "transit:2026-09-15") });
const tomorrow = chartViewEngineCacheKey({ ...base, path: chartViewEngineCachePath("/api/chart", "transit:2026-09-16") });
assert.notEqual(natal, today);
assert.notEqual(today, tomorrow);
assert.equal(chartViewEngineCachePath("/api/chart"), "/api/chart", "unscoped keys are unchanged");
assert.match(serviceSource, /path: chartViewEngineCachePath\(path, cacheScope\)/);
});
+24 -5
View File
@@ -12,12 +12,15 @@ import { peekChartPage, pinChartSnapshotIdentity, prefetchSecondaryPage, resetSe
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8"));
const transitGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
const profile = { name: "Synthetic", date: "1990-06-15", time: "12:00", placeLabel: "Synthetic", latitude: 39.9042, longitude: 116.4074, timezoneOffset: 8, timezoneId: "Asia/Shanghai", ayanamsa: "raman", birthTimeStatus: "confirmed" };
async function packet(layer?: ChartViewLayer, failure?: EngineCallResult) {
return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path) => {
if (path === "/api/chart") return { status: "ok", payload: golden.chart };
// 原值: `/api/chart` 一律回本命 golden;新值: 带 cacheScope 的 `/api/chart`(行运盘)走 failure / 行运 golden
// 原因: 行运层也打 `/api/chart`,只按 cacheScope 区分(TASK-chart-types T4)
return assembleChartView({ userId: "synthetic", accountId: "synthetic", profileFingerprint: "fixture-v1", profile, asOf: "2026-09-15", layers: layer ? [layer] : [], postEngine: async (path, _body, cacheScope) => {
if (path === "/api/chart" && !cacheScope) return { status: "ok", payload: golden.chart };
if (failure) return failure;
const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : golden.western;
const payload = path === "/api/varga_full" ? golden.varga_full : path === "/api/dasha/chara" ? golden.chara : path === "/api/chart" ? transitGolden : golden.western;
return { status: "ok", payload };
} });
}
@@ -81,8 +84,9 @@ for (const cached of [false, true]) test(`chart 401 ${cached ? "cache" : "respon
assert.equal(state().view?.status, "unauthenticated"); assert.deepEqual(assigned, ["/login"]);
}));
// 原值: 含 "qizheng";新值: 去掉;原因: 七政懒加载层已删(TASK-chart-types D1)
for (const layer of ["varga", "chara", "western"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
// 原值: ["varga", "chara", "western", "qizheng"];新值: 去掉 qizheng、加 transit
// 原因: 七政懒加载层已删(TASK-chart-types D1),行运层新增(T4)
for (const layer of ["varga", "chara", "western", "transit"] as const) test(`real assemble to fetch to hook preserves ${layer} engine 429`, async () => harness(async (h, state) => {
const initial = await packet();
const failed = await packet(layer, { status: "busy", path: "/api/synthetic", elapsedMs: 1, httpStatus: 429 });
globalThis.fetch = async (url) => response(String(url).includes("layers=") ? failed : initial);
@@ -143,6 +147,21 @@ test("late warm D1 refresh preserves the layer that completed after mounting", a
assert.equal((state().view as ChartViewOk).western.status, "ok");
}));
test("the transit layer merges beside varga and survives a late warm D1 refresh", async () => harness(async (h, state) => {
const initial = await packet(); const varga = await packet("varga"); const transit = await packet("transit"); const refresh = deferred<Response>();
writeChartPage({ kind: "view", view: initial.body });
globalThis.fetch = async (url) => String(url).includes("layers=transit") ? response(transit) : String(url).includes("layers=varga") ? response(varga) : refresh.promise;
await h.render(null);
await h.update(() => state().requestLayer("transit"));
await h.update(() => state().requestLayer("varga"));
let view = state().view as ChartViewOk;
assert.equal(view.transit.status, "ok"); assert.ok(view.vedic.vargas.some((x) => x.id === "D60"));
await h.update(() => refresh.resolve(response(initial)));
view = state().view as ChartViewOk;
assert.equal(view.transit.status, "ok", "a D1-only refresh does not erase the transit layer");
assert.ok(view.vedic.vargas.some((x) => x.id === "D60"));
}));
test("HTTP 429 without a valid body remains engine_busy", async () => harness(async (h, state) => {
const initial = await packet(); globalThis.fetch = async (url) => String(url).includes("layers=") ? new Response("busy", { status: 429 }) : response(initial);
await h.render(null); await h.update(() => state().requestLayer("western"));
+6 -1
View File
@@ -13,6 +13,7 @@ import {
} from "../src/lib/ephemeris-view.ts";
const routeSource = readFileSync(new URL("../src/app/api/ephemeris/route.ts", import.meta.url), "utf8");
const transitBodySource = readFileSync(new URL("../src/lib/transit-chart-body.ts", import.meta.url), "utf8");
const viewSource = readFileSync(new URL("../src/lib/ephemeris-view.ts", import.meta.url), "utf8");
const frontendRoot = fileURLToPath(new URL("../", import.meta.url));
const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8"));
@@ -172,7 +173,11 @@ test("the ephemeris BFF does not import billing or mastra", () => {
});
test("ephemeris natal and transit chart requests skip VedAstro overview", () => {
assert.equal((routeSource.match(/skip_vedastro_main_entry_overview:\s*true/g) ?? []).length, 2);
// 原值: route.ts 里出现 2 次(natal + transit)
// 新值: route.ts 1 次(natal)+ 共享的 transit-chart-body.ts 1 次(transit)
// 原因: transitChartBody 挪到 lib 给星盘页行运盘共用(TASK-chart-types T4);下面按实际请求逐条断言不变
assert.equal((routeSource.match(/skip_vedastro_main_entry_overview:\s*true/g) ?? []).length, 1);
assert.equal((transitBodySource.match(/skip_vedastro_main_entry_overview:\s*true/g) ?? []).length, 1);
const result = executeRoute({ user: true, row: completeRow });
const chartCalls = result.upstream.filter((item) => item.url.endsWith("/api/chart"));
assert.ok(chartCalls.length >= 2);