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:
co-authored by
Claude Opus 5.5
parent
2071dc7c18
commit
3f6bcb081a
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -191,6 +191,7 @@ export const CHART_VIEW_COPY = {
|
||||
chartTypeSheetTitle: "星盘类型",
|
||||
chartTypeMenu: "全部盘型",
|
||||
chartTypeSheetClose: "关闭星盘类型",
|
||||
transitNote: "以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。",
|
||||
precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。",
|
||||
unauthenticated: "请先登录后再看星盘。",
|
||||
incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user