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
@@ -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);