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,
};
}