diff --git a/frontend/src/app/api/ephemeris/route.ts b/frontend/src/app/api/ephemeris/route.ts index 6ff8552f..15561047 100644 --- a/frontend/src/app/api/ephemeris/route.ts +++ b/frontend/src/app/api/ephemeris/route.ts @@ -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 { - 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, - }; -} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 548a7426..cb4f0697 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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)); } } diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index b717bba1..9fe86d5e 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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" ? : null} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index 8deeb7e1..077215a3 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -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 ( @@ -80,7 +87,7 @@ function AscendantRow({ row }: { row: ChartViewAscendantRow }) { {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} 顺 - {row.house}宫 + {house}宫 {row.nakshatra ?? "—"} {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 (
@@ -113,8 +125,8 @@ function NatalTable({ view, caption }: { view: ChartViewOk; caption: string }) { - {view.vedic.ascendant && } - {view.vedic.planets.map((planet) => ( + {ascendant && } + {planets.map((planet) => ( ))} @@ -189,6 +201,122 @@ function PrecisionHint({ view, id }: { view: ChartViewOk; id: string }) { return

{CHART_VIEW_COPY.precisionHint}

; } +function TransitTable({ transit }: { transit: ChartViewTransitOk }) { + return ( +
+
+ + + + + + + + + + + + {transit.rows.map((row) => { + const key = planetGlyphKey(row.name); + return ( + + + + + + + + ); + })} + +
行运 · {transit.date} {transit.time}({transit.timezoneLabel})
行星今天的位置速度落本命本命同星
+ + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)}{row.retrograde ? "逆" : "顺"}{row.natalHouse}宫{row.natalPlanetHouse === null ? "—" : `${row.natalPlanetHouse}宫`}
+
+ ); +} + +/** 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 ( +
+
+ +
+
+ + {/* 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. */} +

{view.vedic.boundary}

+
+ {children} +
+ ); +} + +function LayerWaiting({ pending, failure, copy }: { pending: boolean; failure?: ChartViewFailureStatus; copy: string }) { + return !pending && failure + ?

{chartLayerFailureCopy(failure)}

+ : ; +} + +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

{CHART_VIEW_COPY.unavailable}

; + return ( + + + + ); + } + if (vargaId === "TRANSIT") { + const transit = view.transit; + if (transit.status !== "ok") return ; + return ( + +
+

{CHART_VIEW_COPY.transitNote}

+ +
+
+ ); + } + 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 ; + return ( + + {varga.id === "D1" || !varga.rows ? ( + + ) : ( +
+ + +
+ )} +
+ ); +} + 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 (
- {waiting || !varga ? ( - !pending && failure ?

{chartLayerFailureCopy(failure)}

- : - ) : ( -
-
- -
-
- - {/* 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. */} -

{view.vedic.boundary}

-
- {varga.id === "D1" || !varga.rows ? ( - - ) : ( -
- - -
- )} -
- )} +
); } diff --git a/frontend/src/components/personal-report/vedic-chart-svg.tsx b/frontend/src/components/personal-report/vedic-chart-svg.tsx index aabdf34d..b00bea6f 100644 --- a/frontend/src/components/personal-report/vedic-chart-svg.tsx +++ b/frontend/src/components/personal-report/vedic-chart-svg.tsx @@ -73,6 +73,12 @@ interface VedicChartSvgProps { chart: NorthIndianChartModel; ariaLabel: string; highlightHouseNumbers?: ReadonlySet; + /** + * 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(); @@ -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); diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index dbecf070..44a1c173 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -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 } : {}), }; } diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 17a9c207..3e90a873 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -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; export type ChartViewResponse = z.infer; export type ChartViewUnavailableLayer = z.infer; export type ChartViewWesternOk = z.infer; +export type ChartViewTransitOk = z.infer; +export type ChartViewMoonChart = z.infer; export type ChartViewVarga = z.infer; export type ChartViewVargaRow = z.infer; export type ChartViewVargaAscendantRow = z.infer; diff --git a/frontend/src/lib/chart-view-engine.ts b/frontend/src/lib/chart-view-engine.ts index 407f48a0..cd80c09d 100644 --- a/frontend/src/lib/chart-view-engine.ts +++ b/frontend/src/lib/chart-view-engine.ts @@ -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 | null { const entry = engineCache().get(key); if (!entry) return null; diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts index 428d1f69..5b0b00df 100644 --- a/frontend/src/lib/chart-view-labels.ts +++ b/frontend/src/lib/chart-view-labels.ts @@ -191,6 +191,7 @@ export const CHART_VIEW_COPY = { chartTypeSheetTitle: "星盘类型", chartTypeMenu: "全部盘型", chartTypeSheetClose: "关闭星盘类型", + transitNote: "以本命上升为第 1 宫,放的是这一天中午的行星位置。岁差和交点与本命相同。", precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。", unauthenticated: "请先登录后再看星盘。", incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。", diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index 5c398e44..23ac94c1 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -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, + cacheScope?: string, ) => Promise; export type ChartViewLoadInput = { @@ -90,8 +98,9 @@ async function callEngine( postEngine: ChartViewEnginePost, path: string, body: Record, + cacheScope?: string, ): Promise { - 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, }); diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 762bdeee..56c3b0bd 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -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 | null; chara: Record | null; western: Record | null; + transit?: Record | null; profile: ChartViewProfileInput; asOf: string; }; @@ -393,6 +399,88 @@ function ascendantRow(chart: Record) { }; } +/** + * 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, vargaPacket: Record | 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 | null | undefined, + chart: Record, + 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, asOf: string) { const dasha = record(chart.dasha) ?? {}; const rawPeriods = Array.isArray(dasha.periods) ? dasha.periods : []; @@ -611,7 +699,7 @@ function mapWestern(raw: Record | 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); } diff --git a/frontend/src/lib/chart-view-service.ts b/frontend/src/lib/chart-view-service.ts index 4af6a9b0..98223547 100644 --- a/frontend/src/lib/chart-view-service.ts +++ b/frontend/src/lib/chart-view-service.ts @@ -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): 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 }; diff --git a/frontend/src/lib/secondary-page-data.ts b/frontend/src/lib/secondary-page-data.ts index eeabe822..14108bfc 100644 --- a/frontend/src/lib/secondary-page-data.ts +++ b/frontend/src/lib/secondary-page-data.ts @@ -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); diff --git a/frontend/src/lib/transit-chart-body.ts b/frontend/src/lib/transit-chart-body.ts new file mode 100644 index 00000000..10b9ef04 --- /dev/null +++ b/frontend/src/lib/transit-chart-body.ts @@ -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 { + 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, + }; +} diff --git a/frontend/tests/chart-moon-transit.test.tsx b/frontend/tests/chart-moon-transit.test.tsx new file mode 100644 index 00000000..61827d1c --- /dev/null +++ b/frontend/tests/chart-moon-transit.test.tsx @@ -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; 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("")[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, /月亮盘 · 以月亮所在星座([^)]+)为第 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, /行运 · 2026-09-15 12:00(Asia\/Shanghai)<\/caption>/); + for (const column of ["行星", "今天的位置", "速度", "落本命", "本命同星"]) assert.match(markup, new RegExp(`${column}`)); + 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) => 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\)/); +}); diff --git a/frontend/tests/chart-page-hook.test.ts b/frontend/tests/chart-page-hook.test.ts index 77b9ab15..f9a7b496 100644 --- a/frontend/tests/chart-page-hook.test.ts +++ b/frontend/tests/chart-page-hook.test.ts @@ -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(); + 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")); diff --git a/frontend/tests/ephemeris-route.test.ts b/frontend/tests/ephemeris-route.test.ts index 8e6de25f..78e9a5a7 100644 --- a/frontend/tests/ephemeris-route.test.ts +++ b/frontend/tests/ephemeris-route.test.ts @@ -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);