Chart page data tables and point layers (issue #114, round 2)
Adds /api/chart_points, a thin route over existing producers (upagrahas, Swiss-Ephemeris special lagnas, Yogi points, arudha padas, friendship), and the chart page's 数据表 (planets, upagrahas, special lagnas, arudhas, shadbala detail, friendship) plus a 标点 layer that marks every point of one group on the board. The ashtakavarga matrix gains the ascendant's own row. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e80bc64d93
commit
941effe803
@@ -1,5 +1,14 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-27 — 星盘页加数据表和标点图层(issue #114 第二轮)
|
||||
|
||||
- 排盘底册多了「数据表」:行星、虚星、特殊点、映射、星力明细、敌友六张。手机上第一列固定,其余左右滑。
|
||||
- 盘上可以一次标出全部虚星、特殊点或映射。点任何一个点,检视里写它是什么、在哪一宫、怎么来的;映射会用虚线连回它来自的那一宫。
|
||||
- 星力明细把六力拆成总表、位置力、时间力和其余四力,都是数字。敌友分天生、临时、综合三种看法。
|
||||
- 八宫点表多了一行「升」(上升自己的点数,不算进合计)。
|
||||
- 特殊点用的日出是 Swiss Ephemeris 算的,不受之前发现的日出错误影响。
|
||||
- Skill 版本不变。
|
||||
|
||||
## 2026-10-27 — 星盘页一页铺开,点星点宫都会联动(issue #114)
|
||||
|
||||
- 星盘页不再分四个页签。上面是盘和「盘面检视」,旁边是九曜、宫位主题、大运和命盘速览,下面「深读」四页:时运流年、能量图谱、命性格局、排盘底册。西洋盘在页头切换。
|
||||
|
||||
@@ -39,3 +39,15 @@
|
||||
## 遗留
|
||||
|
||||
- `chart-vedic-tab.tsx` 里的 `ChartVedicTab` / `VedicLayout`(旧盘面分栏与换盘动效)和 `chart-basics-tab.tsx` 已不被页面使用,仍有 10 个测试文件在测它们;清理需另开一轮连测试一起改。
|
||||
|
||||
## 第二轮:数据表与标点图层(2026-10-27 下午)
|
||||
|
||||
产品贴竞品五张数据表(星力、八分点、映射、特殊点、虚星),原型第 2 版定设计;10-27 13:10 答「1 需要做(标点做成图层)2 可以(数据表不重复大运、瑜伽)」。
|
||||
|
||||
| 块 | 内容 | 位置 |
|
||||
| --- | --- | --- |
|
||||
| 引擎 | 新端点 `/api/chart_points`:直接调既有生产者——`gulika.calculate_upagrahas`、`native_special_lagnas.native_special_lagna_rows`(Swiss Ephemeris 日出,避开 BUG-1462)、`annual_native.yogi_sphuta.build_yogi_sphuta_local`、`jaimini.calc_arudha_padas`、`jyotish_engine._build_planetary_friendship_snapshot`;各块独立失败。主文件只加一段薄注册(类方法数不变,行数 10949 < 11224),模块放子包 `scripts/chart_view_points/`(根目录 py 预算已满 452) | `scripts/chart_view_points/api.py` |
|
||||
| 数据 | 懒层 `points`;六力补位置力 / 时间力分量;八宫点补上升行 | `chart-view-insight.ts`、`chart-view-contract.ts` |
|
||||
| 界面 | 标点图层(虚星 / 特殊点 / 映射)、点的检视、数据表六页、八宫点「升」行 | `chart-board-svg.tsx`、`chart-inspector.tsx`、`chart-deep-reading.tsx`、`use-chart-board.ts` |
|
||||
|
||||
验证:快速门 Python 部分(py_compile、审计、pytest)全过,前端一步失败 25 条=基线无 Docker 库测试条数;`next build` 通过,`/` ○ Static,首屏 gzip 699696 → 701085(+0.20%)。`tests/test_chart_view_points_api.py` 8 条(太阳推算点公式、Avayogi = Yogi + 186°40′、综合敌友 = 天生 + 临时、单块失败不连坐、路由薄注册);`chart-board-20261027.test.tsx` 新增 4 条(金样含 `chart_points`,由同一模块对金样星盘实算)。
|
||||
|
||||
@@ -21,6 +21,14 @@
|
||||
12. 命性格局:只列成立的格局;点一条,盘上只留这几颗星的颜色;「未满足 N 条」默认折起。
|
||||
13. 排盘底册:出生资料表、出生历法(提毗、月宿、星期)、行星名册(有 D9 列)、宫主流向(每宫都有,没有「未提供」)。
|
||||
|
||||
## 第二轮:数据表与标点
|
||||
|
||||
17. 盘卡第二组切到「虚星」:盘上出现 11 枚小胶囊(烟、毁、晕、虹、彗、时、死、利、阎、古、曼);切「特殊点」「映射」同理,映射是 AL、A2…UL。切到 D9 时这一组消失。
|
||||
18. 点盘上的「UL」:它所在的宫高亮,有一条虚线连回第 12 宫;检视写「映射」、传统含义、来历。方向键切到 AL。
|
||||
19. 深读 → 排盘底册 → 数据表:六个页签都有内容;手机上左右滑时第一列不动。点「虚星」表里任一行,盘上那一枚变实心。
|
||||
20. 星力明细切「位置力」「时间力」「其余四力」,数字会变;敌友切「天生」「临时」「综合」。
|
||||
21. 能量图谱的八宫点表,原始口径下第一行是「升」,合计不含它。
|
||||
|
||||
## 对话页
|
||||
|
||||
14. 电脑:新开对话,还没发任何话,右边已经有盘。拖右栏左边缘变宽变窄,刷新后宽度还在;拖到最窄以下会收起,顶栏按钮能再打开。
|
||||
|
||||
+2
-1
@@ -1044,6 +1044,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
- **两栏**(≥1024px,左约 53%、右约 47%;更窄时单列):左列「盘」卡 + 「盘面检视」卡 + 当前盘型自己的表(D1 没有这张,九曜卡就是本命表);右列「九曜」「宫位主题」「Vimshottari 大运」「命盘速览」四张卡。卡片白底(`--color-canvas`)、16px 圆角、一条发丝阴影,标题宋体 16px,不加红色竖条。
|
||||
- **盘卡**:标题「北印度盘 / 南印度盘」,右侧小字「宫位固定 / 星座固定 · 整宫制」。23 种盘型条照旧。下面一行左边四个图层(无图层 / 八宫点 / 宫力 / 叠加行运,只在本命 D1 出现),右边北印度 / 南印度,两组都是分段 `Tabs`。北印度盘上方一条浅底说明条写「本命盘 D1 / 上升 天秤 / 度数 / 当前大运 · 分运」,选中后改写选中的星或宫;南印度把这三行写在盘中央。南北的选择全站只记一份(`localStorage` `chart_board_style`),默认北。
|
||||
- **盘面**(`ChartBoardSvg`,`components/chart-board/`):星体画一字中文名(日月火水木金土罗计)+ 右上小号度数,逆行在度数后加「逆」(产品签过的原型,取代 2026-09-18 起的彩色符号;颜色仍按 `PLANET_COLOR_CLASS` 区分星体,不表示吉凶)。北印度宫内写星座名,上升宫写「升 天秤」并用 `--color-action`;南印度格内左上星座、右上宫号,上升格左上一道斜线。点星:所在宫 `--color-action-soft` 底 + 描边,它管的宫实线、它看的宫虚线(整宫计,火 4/7/8、木 5/7/9、土 3/10/7、罗计 5/7/9),连到的宫浅底,其余宫字淡到 .28。点宫:实线连宫主所在,虚线连看这一宫的星。
|
||||
- **标点图层**(10-27 第二轮,产品选「做成图层」):盘卡工具行第二组分段 `Tabs`「不标点 / 虚星 / 特殊点 / 映射」,只在 D1 出现,可与八宫点等数值图层同时开。每个点画成一枚小胶囊(`chart-board-mark`,主色描边、浅底,一到两个字:烟、古、宫升、AL、UL…),一行最多三枚、多了换行;选中的那枚实心。点胶囊 = 选中这个点:所在宫高亮,映射再用虚线连回它来自的宫;检视面板写名称、英文名、类别徽标、星座度数与宫、传统含义、月宿(映射没有)、所在座的主落哪一宫、映射的来历、同一座里的星,底部「问 AI:这个点」。方向键在同一组里切换。数据走懒层 `points`(`/api/chart_points`)。
|
||||
- **图层**:八宫点在每宫写「N点」,28 及以上 `--color-action`,以下土星色;宫力写引擎宫力数,高于本盘十二宫均值一成偏暖、低一成偏冷;叠加行运把观测日的行运星用土星色小字写在对应星座。图层下一行灰字说明参考线。
|
||||
- **盘面检视**:空时一句「点盘上的星或宫看详情」+「方向键切换 · Esc 收起」。选星:大字 + 名称 + 位置 + 尊贵 / 逆行 / 六力档徽标;分段写星座内位置(0–30° 细条,D1 画月宿分界刻度)、月宿与足、尊贵度(九格:落陷…入旺)、六力(鲁帕、达标百分比、强中弱、竖线是门槛)、视相、它管的宫、主管大运(当前那段实心)、此刻行运(观测日不是今天时写「当日行运」)。选宫:宫主与落宫、宫内星、看这一宫的星、宫力、八宫点。底部「问 AI:这颗星 / 这一宫」(只填输入框并跳到对话,toast「已填进对话输入框,确认后再发送」)和「在 D9 定位」。右上上一个 / 下一个 / 收起三个图标按钮。
|
||||
- **强中弱**:六力按达标百分比,≥120% 强、90%~120% 中、<90% 弱;八宫点 ≥28 强、25~27 中、<25 弱;宫力按本盘均值 ±10%。徽标 `chart-band`:强 `--color-success-muted` 底、中灰、弱 `--color-danger-muted` 底。
|
||||
@@ -1052,7 +1053,7 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
- 时运流年:观测日期(日期框 + 上月 / 下月 / 上年 / 下年 / 回到今天,1000–2999 年任意日);当前运期三层进度条(大运、分运、子运;子运用与分运同一个比例公式,`lib/dasha-span.ts`);九曜行运表(星座、上升起、月亮起、该座八宫点 + 强中弱;表头右侧写土星七年半第几段或未激活);「叠到盘面」;Vimshottari 人生长卷三行等比条(大运 → 点开的分运 → 子运,当前段主色底、观测日一根竖线,620px 以下横滚);最下面「双轨对照」原大运页的 Chara / Yogini 对照照旧。
|
||||
- 能量图谱:行星六力(总量条 + 竖线门槛 + 六段分量彩条,右侧百分比、强中弱、鲁帕);宫位力量柱状图(均值虚线,点柱选宫);八宫点矩阵(按宫位 / 按星座,原始 / 三角削减 / 两步削减,格子按点数深浅,末行合计)。各自「叠到盘面」。
|
||||
- 命性格局:只列与对话卡同一张闭合清单(五大人物、象狮、落陷取消、日水同宫)里成立的,引擎报了削弱的写「部分满足」;没成立的折叠在「未满足 N 条」里。点一条,切回 D1、盘上只留这几颗星的颜色。底部一句「传统格局,本站未验证,只作参考」。
|
||||
- 排盘底册:参数键值表 + 边界句(原样);出生历法(提毗、月宿、星期);行星名册(含 D9 列);宫主流向(第 N 宫之主落第几宫)。
|
||||
- 排盘底册:参数键值表 + 边界句(原样);出生历法(提毗、月宿、星期);「数据表」六个分段页签(2026-10-27 第二轮,产品贴竞品五张表):行星(名册含 D9 列 + 宫主流向)/ 虚星(11 个,烟毁晕虹彗 + 六个时段点)/ 特殊点(八个特殊上升 + 瑜伽点、阻碍点)/ 映射(AL、A2~A11、UL)/ 星力明细(总表、位置力、时间力、其余四力,维鲁帕)/ 敌友(天生 / 临时 / 综合,综合五档)。首列固定,其余横滑。点虚星、特殊点、映射的一行 = 在盘上选中这个点(切回 D1),再点取消;点行星或星力行 = 选星。八宫点不另开表,能量图谱的矩阵在原始口径下多一行「升」(上升自己的 BAV,不计入合计);大运、瑜伽也不在数据表里重复(产品 10-27 同意)。
|
||||
- **等待与失败**:揭幕后不出现骨架或「正在…」:盘型懒层沿用盘位骨架(BUG-1016 例外);八宫点、行运、Chara 未到时那一块留空,失败时写原因句。
|
||||
- **对话右栏**(`ChatNatalChart`):标题「名字 · 本命盘」+「打开星盘页」,下面小签、盘卡、检视、九曜(不带六力列)。桌面默认打开(`chat_chart_panel_open` 没存过即为开),宽 320–760、默认 440,拖到最窄以下收起,宽度记在本机。手机顶栏多一个「看本命盘」图标按钮,从底部拉起同一套盘面(`Drawer`,86dvh),「问 AI」会先收起抽屉再聚焦输入框。样式在 `chart-board.css`,只随星盘页和右栏的懒加载块到达,不进首页首屏。
|
||||
|
||||
|
||||
@@ -19,15 +19,17 @@ import {
|
||||
signLabelAt,
|
||||
transitBySign,
|
||||
vimshottariAt,
|
||||
pointByKey,
|
||||
type BoardSource,
|
||||
type ChartBoardLayer,
|
||||
type PointGroup,
|
||||
} from "@/lib/chart-board-view";
|
||||
import type { ChartViewFailureStatus, ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract";
|
||||
import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels";
|
||||
import { PLANET_NAME_ZH, type PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
|
||||
import { ChartBoardSvg, type ChartBoardHouseNote, type ChartBoardStyle } from "./chart-board-svg";
|
||||
import { ChartBoardSvg, type ChartBoardHouseNote, type ChartBoardPointMark, type ChartBoardStyle } from "./chart-board-svg";
|
||||
|
||||
const LAYERS: ReadonlyArray<readonly [ChartBoardLayer, string]> = [
|
||||
["none", "无图层"],
|
||||
@@ -64,6 +66,14 @@ export function boardCaption(view: ChartViewOk, source: BoardSource, model: Boar
|
||||
detail: body ? `${signLabelAt(body.sign)} ${formatDegreeMinutes(body.degree)} · 第${body.house}宫` : "",
|
||||
};
|
||||
}
|
||||
if (selection?.kind === "point") {
|
||||
const point = pointByKey(view, selection.group, selection.key);
|
||||
return {
|
||||
kicker: `D1 · 选中`,
|
||||
title: point?.label ?? selection.key,
|
||||
detail: point ? `${point.signLabel} · 第${point.house}宫` : "",
|
||||
};
|
||||
}
|
||||
if (selection?.kind === "house") {
|
||||
const sign = model.houses[selection.house - 1]?.sign ?? -1;
|
||||
return {
|
||||
@@ -102,6 +112,12 @@ export function ChartBoardCard({
|
||||
transit,
|
||||
transitPending = false,
|
||||
compact = false,
|
||||
pointLayer = "none",
|
||||
onPointLayer,
|
||||
pointMarks = [],
|
||||
onPointSelect,
|
||||
pointsPending = false,
|
||||
pointsFailure,
|
||||
}: {
|
||||
view: ChartViewOk;
|
||||
type: ChartTypeId;
|
||||
@@ -125,6 +141,12 @@ export function ChartBoardCard({
|
||||
transit: ChartViewTransitOk | null;
|
||||
transitPending?: boolean;
|
||||
compact?: boolean;
|
||||
pointLayer?: PointGroup | "none";
|
||||
onPointLayer?: (layer: PointGroup | "none") => void;
|
||||
pointMarks?: readonly ChartBoardPointMark[];
|
||||
onPointSelect?: (key: string) => void;
|
||||
pointsPending?: boolean;
|
||||
pointsFailure?: ChartViewFailureStatus;
|
||||
}) {
|
||||
const natal = source?.natal ?? false;
|
||||
const activeLayer = natal ? layer : "none";
|
||||
@@ -150,6 +172,16 @@ export function ChartBoardCard({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : <span />}
|
||||
{natal && onPointLayer ? (
|
||||
<Tabs value={pointLayer} onValueChange={(value) => value && onPointLayer(value as PointGroup | "none")}>
|
||||
<TabsList variant="default" aria-label="标点">
|
||||
<TabsTrigger value="none" className="chart-board-tab">不标点</TabsTrigger>
|
||||
<TabsTrigger value="upagraha" className="chart-board-tab">虚星</TabsTrigger>
|
||||
<TabsTrigger value="special" className="chart-board-tab">特殊点</TabsTrigger>
|
||||
<TabsTrigger value="arudha" className="chart-board-tab">映射</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
<Tabs value={style} onValueChange={(value) => value && onStyle(value as ChartBoardStyle)}>
|
||||
<TabsList variant="default" aria-label="盘式">
|
||||
<TabsTrigger value="north" className="chart-board-tab">北印度</TabsTrigger>
|
||||
@@ -181,6 +213,8 @@ export function ChartBoardCard({
|
||||
overlay={overlay.map(({ key, sign }) => ({ key, sign }))}
|
||||
dimmedBodies={dimmedBodies}
|
||||
center={style === "south" && caption ? caption : undefined}
|
||||
pointMarks={pointMarks}
|
||||
onPointSelect={onPointSelect}
|
||||
/>
|
||||
</div>
|
||||
) : !pending && failure ? (
|
||||
@@ -204,6 +238,9 @@ export function ChartBoardCard({
|
||||
{natal && activeLayer === "bhava" && mean !== null ? (
|
||||
<p className="chart-board-layer-note">宫力:本盘十二宫均值 {Math.round(mean)},高出一成记「{BAND_ZH.strong}」,低一成记「{BAND_ZH.weak}」。</p>
|
||||
) : null}
|
||||
{natal && pointLayer !== "none" && pointsFailure && !pointsPending ? (
|
||||
<p className="chart-page-note">{chartLayerFailureCopy(pointsFailure)}</p>
|
||||
) : null}
|
||||
{natal && activeLayer === "transit" && transit ? (
|
||||
<p className="chart-board-layer-note">蓝字是 {transit.date} 的行运位置。</p>
|
||||
) : null}
|
||||
|
||||
@@ -40,6 +40,9 @@ export type ChartBoardHouseNote = { text: string; tone: "high" | "low" | "plain"
|
||||
|
||||
export type ChartBoardOverlay = { key: PlanetGlyphKey; sign: number };
|
||||
|
||||
/** A data-table point drawn as a small tag in its sign (issue #114 标点 layer). */
|
||||
export type ChartBoardPointMark = { key: string; short: string; label: string; sign: number };
|
||||
|
||||
/** Sign cell of the South Indian board: column, row on a 4 × 4 grid. */
|
||||
const SOUTH_CELL: Readonly<Record<number, readonly [number, number]>> = {
|
||||
11: [0, 0], 0: [1, 0], 1: [2, 0], 2: [3, 0],
|
||||
@@ -120,6 +123,8 @@ export function ChartBoardSvg({
|
||||
overlay = [],
|
||||
dimmedBodies,
|
||||
center,
|
||||
pointMarks = [],
|
||||
onPointSelect,
|
||||
}: {
|
||||
model: BoardModel;
|
||||
style: ChartBoardStyle;
|
||||
@@ -133,6 +138,8 @@ export function ChartBoardSvg({
|
||||
dimmedBodies?: ReadonlySet<PlanetGlyphKey>;
|
||||
/** South Indian centre: three lines. */
|
||||
center?: { kicker: string; title: string; detail: string; footer?: string };
|
||||
pointMarks?: readonly ChartBoardPointMark[];
|
||||
onPointSelect?: (key: string) => void;
|
||||
}) {
|
||||
const cells = cellsFor(model, style);
|
||||
const byHouse = new Map(cells.map((cell) => [cell.house, cell]));
|
||||
@@ -142,6 +149,9 @@ export function ChartBoardSvg({
|
||||
const overlayBySign = new Map<number, PlanetGlyphKey[]>();
|
||||
for (const item of overlay) overlayBySign.set(item.sign, [...(overlayBySign.get(item.sign) ?? []), item.key]);
|
||||
const focusHub = relations.focus !== null ? byHouse.get(relations.focus)?.hub : undefined;
|
||||
const marksBySign = new Map<number, ChartBoardPointMark[]>();
|
||||
for (const mark of pointMarks) marksBySign.set(mark.sign, [...(marksBySign.get(mark.sign) ?? []), mark]);
|
||||
const selectedPoint = selection?.kind === "point" ? selection.key : null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -271,6 +281,48 @@ export function ChartBoardSvg({
|
||||
})}
|
||||
</g>
|
||||
) : null}
|
||||
{cells.map((cell) => {
|
||||
const marks = marksBySign.get(cell.sign) ?? [];
|
||||
if (marks.length === 0) return null;
|
||||
// Tags sit in one or two short rows: along the cell's lower edge (South), or
|
||||
// between the sign label and the bodies (North).
|
||||
const perRow = style === "south" ? 3 : cell.triangle ? 2 : 3;
|
||||
const base = style === "south" && cell.shape.kind === "rect"
|
||||
? { x: cell.shape.x + CELL / 2, y: cell.shape.y + CELL - 26 }
|
||||
: { x: cell.hub.x, y: cell.triangle ? cell.hub.y + (cell.anchor.y > cell.hub.y ? -30 : 34) : cell.hub.y + 30 };
|
||||
return (
|
||||
<g key={`marks-${cell.house}`} className="chart-board-marks">
|
||||
{marks.map((mark, index) => {
|
||||
const row = Math.floor(index / perRow);
|
||||
const inRow = Math.min(perRow, marks.length - row * perRow);
|
||||
const col = index % perRow;
|
||||
const width = Math.max(18, mark.short.length * 10 + 8);
|
||||
const x = base.x + (col - (inRow - 1) / 2) * 30;
|
||||
const y = base.y + row * 15;
|
||||
const on = selectedPoint === mark.key;
|
||||
const pick = () => onPointSelect?.(mark.key);
|
||||
return (
|
||||
<g
|
||||
key={mark.key}
|
||||
className={on ? "chart-board-mark is-selected" : "chart-board-mark"}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${mark.label},${signZh(SIGN_ORDER[cell.sign] ?? "")},第${cell.house}宫`}
|
||||
aria-pressed={on}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
pick();
|
||||
}}
|
||||
onKeyDown={(event) => activateOnKey(event, pick)}
|
||||
>
|
||||
<rect x={x - width / 2} y={y - 9} width={width} height={13} rx={6.5} />
|
||||
<text x={x} y={y + 1} textAnchor="middle">{mark.short}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{style === "south" && center ? (
|
||||
<g className="chart-board-center-text" aria-hidden="true">
|
||||
<text className="chart-board-center-kicker" x={CHART_SIZE / 2} y={CHART_SIZE / 2 - 34} textAnchor="middle">{center.kicker}</text>
|
||||
|
||||
@@ -322,3 +322,29 @@
|
||||
.chart-inspector { min-width: 0; }
|
||||
.chart-scroll-row { min-width: 0; }
|
||||
.chat-chart-sheet { --drawer-height: 86dvh; }
|
||||
|
||||
/* 标点 layer: small tags for upagrahas, special lagnas and arudha padas. */
|
||||
.chart-board-mark { cursor: pointer; outline: none; }
|
||||
.chart-board-mark rect { fill: var(--color-action-soft); stroke: var(--color-action); stroke-width: .8; }
|
||||
.chart-board-mark text { font-size: 9px; fill: var(--color-action); font-weight: 600; }
|
||||
.chart-board-mark.is-selected rect { fill: var(--color-action); }
|
||||
.chart-board-mark.is-selected text { fill: var(--color-canvas); }
|
||||
.chart-board-mark:focus-visible rect { stroke: var(--color-focus); stroke-width: 2; }
|
||||
.chart-inspector-glyph.is-point { font-size: 15px; }
|
||||
.chart-inspector-english { font-family: var(--font-body); font-size: 12px; font-weight: 400; color: var(--color-ink-tertiary); }
|
||||
|
||||
/* 数据表 (issue #114). The first column stays put while the rest scrolls on a phone. */
|
||||
.chart-book-tabs { max-width: 100%; overflow-x: auto; }
|
||||
.chart-graha-table.is-sticky tbody th,
|
||||
.chart-graha-table.is-sticky thead th:first-child { position: sticky; left: 0; z-index: 1; background: var(--color-canvas); }
|
||||
.chart-graha-table.is-sticky tbody tr.is-selected > th { background: var(--color-action-soft); }
|
||||
.chart-graha-name small { margin-left: 4px; }
|
||||
.chart-point-tag { display: inline-grid; place-items: center; min-width: 22px; height: 18px; padding: 0 5px; margin-right: 6px; border-radius: 9px; border: 1px solid var(--color-action); background: var(--color-action-soft); color: var(--color-action); font-size: 11px; font-weight: 600; }
|
||||
.chart-point-note { white-space: normal; min-width: 12em; color: var(--color-ink-secondary); font-size: 12px; }
|
||||
.chart-relation-table th,
|
||||
.chart-relation-table td { text-align: center; }
|
||||
.chart-relation-table tbody th { text-align: left; }
|
||||
.chart-relation-self { color: var(--color-ink-tertiary); }
|
||||
.chart-av-lagna th,
|
||||
.chart-av-lagna td span { color: var(--color-ink-secondary); }
|
||||
.chart-board-marks { pointer-events: auto; }
|
||||
|
||||
@@ -21,11 +21,13 @@ import {
|
||||
bhavaBand,
|
||||
bhavaMean,
|
||||
natalBoard,
|
||||
pointsOf,
|
||||
progressOf,
|
||||
savBand,
|
||||
SAV_REFERENCE,
|
||||
signLabelAt,
|
||||
vimshottariAt,
|
||||
type PointGroup,
|
||||
} from "@/lib/chart-board-view";
|
||||
import type { ChartViewFailureStatus, ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
@@ -432,6 +434,13 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reading === "raw" && av.lagnaBav ? (
|
||||
<tr className="chart-av-lagna">
|
||||
<th scope="row">升</th>
|
||||
{columns.map((sign) => <td key={sign}><span>{av.lagnaBav![sign] ?? 0}</span></td>)}
|
||||
<td className="chart-av-sum">{av.lagnaBav.reduce((sum, value) => sum + value, 0)}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{av.bav.map((row) => {
|
||||
const values = row[reading];
|
||||
const key = planetGlyphKey(row.name);
|
||||
@@ -458,7 +467,7 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
|
||||
</Table>
|
||||
</div>
|
||||
<div className="chart-deep-foot">
|
||||
<span>{SAV_REFERENCE} 是传统参考线,不是本盘均值。</span>
|
||||
<span>{SAV_REFERENCE} 是传统参考线,不是本盘均值。「升」是上升自己的一行,不计入合计。</span>
|
||||
<Button type="button" variant="link" size="sm" onClick={() => { board.setType("D1"); board.setLayer("sav"); }}>叠到盘面</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -529,10 +538,198 @@ function YogaTab({ view, board }: { view: ChartViewOk; board: ChartBoardState })
|
||||
);
|
||||
}
|
||||
|
||||
function BookTab({ view }: { view: ChartViewOk }) {
|
||||
type BookTable = "planets" | "upagraha" | "special" | "arudha" | "strength" | "relation";
|
||||
|
||||
const BOOK_TABLES: ReadonlyArray<readonly [BookTable, string]> = [
|
||||
["planets", "行星"],
|
||||
["upagraha", "虚星"],
|
||||
["special", "特殊点"],
|
||||
["arudha", "映射"],
|
||||
["strength", "星力明细"],
|
||||
["relation", "敌友"],
|
||||
];
|
||||
|
||||
const STHANA_PARTS = [["uccha", "高位力"], ["saptavarga", "七分盘力"], ["ojayugma", "奇偶力"], ["kendra", "角宫力"], ["drekkana", "三分力"]] as const;
|
||||
const KALA_PARTS = [["nathonnata", "昼夜力"], ["paksha", "月相力"], ["tribhaga", "三分时力"], ["ayana", "至力"], ["hora", "时主力"]] as const;
|
||||
const RELATION_ZH: Readonly<Record<number, string>> = { 2: "大友", 1: "友", 0: "中", [-1]: "敌", [-2]: "大敌" };
|
||||
|
||||
function PointTable({ view, board, group }: { view: ChartViewOk; board: ChartBoardState; group: PointGroup }) {
|
||||
const rows = pointsOf(view, group);
|
||||
if (!rows) return <p className="chart-page-note">这张表暂时没算出来。</p>;
|
||||
const picked = board.selection?.kind === "point" ? board.selection.key : null;
|
||||
const arudha = group === "arudha";
|
||||
return (
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">名称</th>
|
||||
<th scope="col">{arudha ? "星座" : "黄道经度"}</th>
|
||||
<th scope="col">宫位</th>
|
||||
{arudha ? <th scope="col">来自</th> : <><th scope="col">月宿</th><th scope="col">宿主</th><th scope="col">足</th></>}
|
||||
<th scope="col">传统含义</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((point) => {
|
||||
const on = picked === point.key;
|
||||
const pick = () => board.pickPoint(on ? null : point);
|
||||
return (
|
||||
<tr key={point.key} className={on ? "is-selected" : undefined} aria-selected={on} onClick={pick}>
|
||||
<th scope="row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="chart-graha-name h-auto px-0"
|
||||
aria-pressed={on}
|
||||
onClick={(event) => { event.stopPropagation(); pick(); }}
|
||||
>
|
||||
<span className="chart-point-tag">{point.short}</span>
|
||||
{point.label}
|
||||
<small>{point.english}</small>
|
||||
</Button>
|
||||
</th>
|
||||
<td className="chart-graha-num">{point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""}</td>
|
||||
<td className="chart-graha-num">{point.house}宫</td>
|
||||
{arudha
|
||||
? <td>第 {point.sourceHouse ?? "—"} 宫 · 主 {point.sourceLord ?? "—"}</td>
|
||||
: <><td>{point.nakshatra ?? "—"}</td><td>{point.nakshatraLord ?? "—"}</td><td className="chart-graha-num">{point.pada ?? "—"}</td></>}
|
||||
<td className="chart-point-note">{point.note}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrengthTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const [part, setPart] = useState<"total" | "sthana" | "kala" | "other">("total");
|
||||
const rows = view.shadbala ?? [];
|
||||
if (rows.length === 0) return <p className="chart-page-note">这张盘没有返回六力。</p>;
|
||||
const current = board.selection?.kind === "body" ? board.selection.key : null;
|
||||
const head = part === "total" ? ["总分", "达标分", "达标鲁帕", "满足率", "差额"]
|
||||
: part === "sthana" ? [...STHANA_PARTS.map(([, label]) => label), "小计"]
|
||||
: part === "kala" ? [...KALA_PARTS.map(([, label]) => label), "小计"]
|
||||
: ["方位力", "运动力", "自然力", "视相力"];
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={part} onValueChange={(value) => value && setPart(value as typeof part)}>
|
||||
<TabsList variant="default" aria-label="六力视图">
|
||||
<TabsTrigger value="total" className="chart-board-tab">总表</TabsTrigger>
|
||||
<TabsTrigger value="sthana" className="chart-board-tab">位置力</TabsTrigger>
|
||||
<TabsTrigger value="kala" className="chart-board-tab">时间力</TabsTrigger>
|
||||
<TabsTrigger value="other" className="chart-board-tab">其余四力</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead><tr><th scope="col">行星</th>{head.map((label) => <th key={label} scope="col">{label}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const on = key !== null && current === key;
|
||||
const total = row.rupas * 60;
|
||||
const required = row.required * 60;
|
||||
let cells: ReactNode;
|
||||
if (part === "total") {
|
||||
cells = (
|
||||
<>
|
||||
<td className="chart-graha-num">{total.toFixed(1)}</td>
|
||||
<td className="chart-graha-num">{required.toFixed(0)}</td>
|
||||
<td className="chart-graha-num">{row.required.toFixed(2)}</td>
|
||||
<td className="chart-graha-num">{row.percent.toFixed(1)}%<span className={`chart-band is-${row.level}`}>{STRENGTH_LEVEL_ZH[row.level]}</span></td>
|
||||
<td className={`chart-graha-num ${total >= required ? "chart-graha-good" : "chart-graha-bad"}`}>{total >= required ? "+" : ""}{(total - required).toFixed(1)}</td>
|
||||
</>
|
||||
);
|
||||
} else if (part === "other") {
|
||||
cells = row.parts
|
||||
? ["dig", "chesta", "naisargika", "drik"].map((id) => <td key={id} className="chart-graha-num">{row.parts![id as "dig"].toFixed(1)}</td>)
|
||||
: <td colSpan={4}>—</td>;
|
||||
} else {
|
||||
const list = part === "sthana" ? STHANA_PARTS : KALA_PARTS;
|
||||
const values = part === "sthana" ? row.sthanaParts : row.kalaParts;
|
||||
cells = values
|
||||
? <>
|
||||
{list.map(([id]) => <td key={id} className="chart-graha-num">{(values as Record<string, number>)[id]!.toFixed(1)}</td>)}
|
||||
<td className="chart-graha-num"><strong>{(part === "sthana" ? row.parts?.sthana : row.parts?.kala)?.toFixed(1) ?? "—"}</strong></td>
|
||||
</>
|
||||
: <td colSpan={list.length + 1}>—</td>;
|
||||
}
|
||||
return (
|
||||
<tr key={row.name} className={on ? "is-selected" : undefined} onClick={() => key && board.select(on ? null : { kind: "body", key })}>
|
||||
<th scope="row">{row.label}</th>
|
||||
{cells}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="chart-board-layer-note">单位维鲁帕,60 维鲁帕 = 1 鲁帕。图形版在「能量图谱」。</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationTable({ view }: { view: ChartViewOk }) {
|
||||
const [mode, setMode] = useState<"natural" | "temporary" | "compound">("compound");
|
||||
const friendship = view.points?.status === "ok" ? view.points.friendship : null;
|
||||
if (!friendship) return <p className="chart-page-note">这张表暂时没算出来。</p>;
|
||||
const names = friendship.rows.map((row) => row.name);
|
||||
const label = (value: number) => mode === "compound" ? RELATION_ZH[value] ?? "—" : value > 0 ? "友" : value < 0 ? "敌" : "中";
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={mode} onValueChange={(value) => value && setMode(value as typeof mode)}>
|
||||
<TabsList variant="default" aria-label="敌友口径">
|
||||
<TabsTrigger value="natural" className="chart-board-tab">天生</TabsTrigger>
|
||||
<TabsTrigger value="temporary" className="chart-board-tab">临时</TabsTrigger>
|
||||
<TabsTrigger value="compound" className="chart-board-tab">综合</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky chart-relation-table">
|
||||
<thead>
|
||||
<tr><th scope="col"><span className="sr-only">星体</span></th>{names.map((name) => <th key={name} scope="col">{PLANET_GLYPH_ZH[planetGlyphKey(name) ?? "sun"]}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{friendship.rows.map((row) => (
|
||||
<tr key={row.name}>
|
||||
<th scope="row">{row.label}</th>
|
||||
{names.map((target) => {
|
||||
const cell = row.targets.find((item) => item.name === target);
|
||||
if (!cell) return <td key={target} className="chart-relation-self">—</td>;
|
||||
const value = cell[mode];
|
||||
return <td key={target} className={value > 0 ? "chart-graha-good" : value < 0 ? "chart-graha-bad" : undefined}>{label(value)}</td>;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="chart-board-layer-note">行是这颗星怎么看列上的星。临时关系看两星在盘上的相对位置(第 2、3、4、10、11、12 位为友);综合是两者合起来分五档。</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BookTab({ view, board, onNeedLayer }: {
|
||||
view: ChartViewOk;
|
||||
board: ChartBoardState;
|
||||
onNeedLayer?: (layer: ChartViewLayer) => void;
|
||||
}) {
|
||||
const natal = natalBoard(view);
|
||||
const calendar = view.birthCalendar;
|
||||
const moon = view.vedic.planets.find((row) => row.name === "Moon");
|
||||
const [table, setTable] = useState<BookTable>("planets");
|
||||
// The data tables read the points layer.
|
||||
useEffect(() => {
|
||||
onNeedLayer?.("points");
|
||||
}, [onNeedLayer]);
|
||||
const pointsPending = view.points?.status !== "ok" && board.pointsPending;
|
||||
return (
|
||||
<div className="chart-deep-body">
|
||||
<Section title="出生资料与计算口径">
|
||||
@@ -548,67 +745,77 @@ function BookTab({ view }: { view: ChartViewOk }) {
|
||||
</dl>
|
||||
</Section>
|
||||
) : null}
|
||||
<div className="chart-deep-two">
|
||||
<Section title="行星名册" hint="D1">
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">星体</th>
|
||||
<th scope="col">位置</th>
|
||||
<th scope="col">月宿</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">D9</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.vedic.planets.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const dignity = dignityStep(row.status);
|
||||
return (
|
||||
<tr key={row.name}>
|
||||
<th scope="row">
|
||||
{key ? <span className={`chart-graha-glyph ${PLANET_COLOR_CLASS[key]}`}>{PLANET_GLYPH_ZH[key]}</span> : null}
|
||||
{row.label}
|
||||
</th>
|
||||
<td className="chart-graha-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫</td>
|
||||
<td>{row.nakshatra}</td>
|
||||
<td>{dignity?.label ?? "—"}{row.retrograde ? " · 逆" : ""}</td>
|
||||
<td>{row.d9SignLabel ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
<Section title="数据表">
|
||||
<Tabs value={table} onValueChange={(value) => value && setTable(value as BookTable)}>
|
||||
<TabsList variant="default" aria-label="数据表" className="chart-book-tabs">
|
||||
{BOOK_TABLES.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{table === "planets" ? (
|
||||
<div className="chart-deep-two">
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">星体</th>
|
||||
<th scope="col">位置</th>
|
||||
<th scope="col">月宿</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">D9</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.vedic.planets.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const dignity = dignityStep(row.status);
|
||||
return (
|
||||
<tr key={row.name} onClick={() => key && board.select({ kind: "body", key })}>
|
||||
<th scope="row">
|
||||
{key ? <span className={`chart-graha-glyph ${PLANET_COLOR_CLASS[key]}`}>{PLANET_GLYPH_ZH[key]}</span> : null}
|
||||
{row.label}
|
||||
</th>
|
||||
<td className="chart-graha-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫</td>
|
||||
<td>{row.nakshatra}</td>
|
||||
<td>{dignity?.label ?? "—"}{row.retrograde ? " · 逆" : ""}</td>
|
||||
<td>{row.d9SignLabel ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">宫主流向</th>
|
||||
<th scope="col">宫主</th>
|
||||
<th scope="col">落宫</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{natal.houses.map((house) => {
|
||||
const lord = houseLord(natal, house.house);
|
||||
const at = lord ? bodyHouse(natal, lord) : null;
|
||||
return (
|
||||
<tr key={house.house} onClick={() => board.select({ kind: "house", house: house.house })}>
|
||||
<th scope="row">{house.house}宫 · {signLabelAt(house.sign)}</th>
|
||||
<td>{lord ? PLANET_NAME_ZH[lord] : "—"}</td>
|
||||
<td>{at ? `${at}宫 · ${signLabelAt(natal.houses[at - 1]!.sign)}` : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="宫主流向" hint="第 N 宫之主落在哪一宫">
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">宫位</th>
|
||||
<th scope="col">宫主</th>
|
||||
<th scope="col">落宫</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{natal.houses.map((house) => {
|
||||
const lord = houseLord(natal, house.house);
|
||||
const at = lord ? bodyHouse(natal, lord) : null;
|
||||
return (
|
||||
<tr key={house.house}>
|
||||
<th scope="row">{house.house}宫 · {signLabelAt(house.sign)}</th>
|
||||
<td>{lord ? PLANET_NAME_ZH[lord] : "—"}</td>
|
||||
<td>{at ? `${at}宫 · ${signLabelAt(natal.houses[at - 1]!.sign)}` : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
{table === "upagraha" || table === "special" || table === "arudha" ? (
|
||||
pointsPending ? <p className="chart-page-note" aria-busy="true"></p> : <PointTable view={view} board={board} group={table} />
|
||||
) : null}
|
||||
{table === "strength" ? <StrengthTable view={view} board={board} /> : null}
|
||||
{table === "relation" ? (pointsPending ? <p className="chart-page-note" aria-busy="true"></p> : <RelationTable view={view} />) : null}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -685,7 +892,7 @@ export function ChartDeepReading({
|
||||
<EnergyTab view={view} board={board} onNeedLayer={onNeedLayer} pending={pendingLayers?.has("ashtakavarga") ?? false} failure={layerFailures?.get("ashtakavarga")} />
|
||||
) : null}
|
||||
{tab === "yoga" ? <YogaTab view={view} board={board} /> : null}
|
||||
{tab === "book" ? <BookTab view={view} /> : null}
|
||||
{tab === "book" ? <BookTab view={view} board={board} onNeedLayer={onNeedLayer} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DIGNITY_STEP_COUNT,
|
||||
houseLord,
|
||||
lordedHouses,
|
||||
signIndex,
|
||||
type BoardModel,
|
||||
type BoardSelection,
|
||||
} from "@/lib/chart-board-model";
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
HOUSE_THEME_ZH,
|
||||
nakshatraTicks,
|
||||
planetRowFor,
|
||||
pointByKey,
|
||||
POINT_GROUP_ZH,
|
||||
savBand,
|
||||
savByHouse,
|
||||
shadbalaFor,
|
||||
@@ -33,7 +36,7 @@ import {
|
||||
transitBySign,
|
||||
type BoardSource,
|
||||
} from "@/lib/chart-board-view";
|
||||
import type { ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewOk, ChartViewPoint, ChartViewTransitOk } from "@/lib/chart-view-contract";
|
||||
import { STRENGTH_LEVEL_ZH } from "@/lib/chart-view-insight";
|
||||
import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, PLANET_NAME_ZH, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
@@ -44,9 +47,36 @@ export function inspectorQuestion(view: ChartViewOk, model: BoardModel, selectio
|
||||
const sign = house ? signLabelAt(model.houses[house - 1]!.sign) : "";
|
||||
return `我的${PLANET_NAME_ZH[selection.key]}在${sign}第${house ?? "—"}宫,对我意味着什么?`;
|
||||
}
|
||||
if (selection.kind === "point") {
|
||||
const point = pointByKey(view, selection.group, selection.key);
|
||||
if (!point) return "";
|
||||
return `我的${point.label}(${point.english})在${point.signLabel}第${point.house}宫,说明什么?`;
|
||||
}
|
||||
return `我的第${selection.house}宫(${HOUSE_THEME_ZH[selection.house - 1]})怎么样?`;
|
||||
}
|
||||
|
||||
function PointDetail({ model, point }: { model: BoardModel; point: ChartViewPoint }) {
|
||||
const sign = model.houses.find((house) => house.sign === signIndex(point.sign));
|
||||
const lord = sign ? houseLord(model, sign.house) : null;
|
||||
const lordAt = lord ? bodyHouse(model, lord) : null;
|
||||
const companions = sign?.bodies.map((body) => PLANET_NAME_ZH[body.key]) ?? [];
|
||||
return (
|
||||
<>
|
||||
<Section label="传统含义" value={point.note} />
|
||||
{point.nakshatra ? (
|
||||
<Section label="月宿" value={`${point.nakshatra} · 第 ${point.pada ?? "—"} 足${point.nakshatraLord ? ` · 宿主 ${point.nakshatraLord}` : ""}`} />
|
||||
) : null}
|
||||
<Section label="所在座的主" value={lord ? `${PLANET_NAME_ZH[lord]}${lordAt ? ` · 落第 ${lordAt} 宫` : ""}` : "—"} />
|
||||
{point.sourceHouse ? (
|
||||
<Section label="怎么来的" value={`第 ${point.sourceHouse} 宫之主${point.sourceLord ?? ""},从它再数同样多宫`}>
|
||||
<p className="chart-inspector-text">虚线连回第 {point.sourceHouse} 宫。</p>
|
||||
</Section>
|
||||
) : null}
|
||||
<Section label="同一座里的星" value={companions.length > 0 ? companions.join("、") : "无"} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, value, children }: { label: string; value?: string; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="chart-inspector-section">
|
||||
@@ -230,6 +260,43 @@ export function ChartInspector({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (selection.kind === "point") {
|
||||
const point = pointByKey(view, selection.group, selection.key);
|
||||
const house = model.houses.find((item) => item.sign === selection.sign)?.house ?? null;
|
||||
return (
|
||||
<Card className="chart-board-card chart-inspector">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">盘面检视</CardTitle>
|
||||
<CardAction className="chart-inspector-nav">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="上一个" onClick={() => onStep(-1)}><ChevronLeft aria-hidden="true" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="下一个" onClick={() => onStep(1)}><ChevronRight aria-hidden="true" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="收起检视" onClick={() => onSelect(null)}><X aria-hidden="true" /></Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="chart-inspector-body">
|
||||
{point ? (
|
||||
<>
|
||||
<div className="chart-inspector-head">
|
||||
<span className="chart-inspector-glyph is-point">{point.short}</span>
|
||||
<div>
|
||||
<h3>{point.label} <small className="chart-inspector-english">{point.english}</small></h3>
|
||||
<p>{point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""} · 第{house ?? point.house}宫</p>
|
||||
<div className="chart-chip-row"><Badge variant="secondary">{POINT_GROUP_ZH[point.group]}</Badge></div>
|
||||
</div>
|
||||
</div>
|
||||
<PointDetail model={model} point={point} />
|
||||
<div className="chart-inspector-actions">
|
||||
<Button type="button" onClick={() => onAsk(inspectorQuestion(view, model, selection))}>
|
||||
<MessageCircleQuestion aria-hidden="true" />
|
||||
问 AI:这个点
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : <p className="chart-inspector-empty">这个点暂时没算出来</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const isBody = selection.kind === "body";
|
||||
const house = isBody ? bodyHouse(model, selection.key) : selection.house;
|
||||
const sign = house ? model.houses[house - 1]?.sign ?? -1 : -1;
|
||||
|
||||
@@ -162,6 +162,12 @@ function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, deepT
|
||||
layerPending={board.layerPending}
|
||||
layerFailure={board.layerFailure}
|
||||
dimmedBodies={board.dimmedBodies}
|
||||
pointLayer={board.pointLayer}
|
||||
onPointLayer={board.setPointLayer}
|
||||
pointMarks={board.pointMarks}
|
||||
onPointSelect={board.selectPoint}
|
||||
pointsPending={board.pointsPending}
|
||||
pointsFailure={board.pointsFailure}
|
||||
transit={transit.transit}
|
||||
transitPending={transit.pending}
|
||||
/>
|
||||
|
||||
@@ -61,6 +61,12 @@ function PanelBoard({ view, pendingLayers, layerFailures, onNeedLayer, onAsked }
|
||||
layerPending={board.layerPending}
|
||||
layerFailure={board.layerFailure}
|
||||
dimmedBodies={board.dimmedBodies}
|
||||
pointLayer={board.pointLayer}
|
||||
onPointLayer={board.setPointLayer}
|
||||
pointMarks={board.pointMarks}
|
||||
onPointSelect={board.selectPoint}
|
||||
pointsPending={board.pointsPending}
|
||||
pointsFailure={board.pointsFailure}
|
||||
transit={transit.transit}
|
||||
transitPending={transit.pending}
|
||||
compact
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type KeyboardEvent } from "react";
|
||||
|
||||
import { stepSelection, type BoardSelection } from "@/lib/chart-board-model";
|
||||
import { boardModelFor, boardSource, type ChartBoardLayer } from "@/lib/chart-board-view";
|
||||
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import {
|
||||
boardModelFor,
|
||||
boardSource,
|
||||
pointSelection,
|
||||
pointsOf,
|
||||
stepPoint,
|
||||
type ChartBoardLayer,
|
||||
type PointGroup,
|
||||
} from "@/lib/chart-board-view";
|
||||
import type { ChartViewFailureStatus, ChartViewOk, ChartViewPoint } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import type { ChartTypeId } from "@/lib/chart-view-labels";
|
||||
import type { PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
@@ -49,6 +57,8 @@ export function useChartBoard({
|
||||
const [selection, setSelection] = useState<BoardSelection>(null);
|
||||
const [layer, setLayerState] = useState<ChartBoardLayer>("none");
|
||||
const [yogaBodies, setYogaBodies] = useState<readonly PlanetGlyphKey[] | null>(null);
|
||||
/** 标点 layer: every point of one group drawn on the board at once (issue #114). */
|
||||
const [pointLayer, setPointLayerState] = useState<PointGroup | "none">("none");
|
||||
|
||||
useEffect(() => {
|
||||
hydrateStyle();
|
||||
@@ -77,10 +87,51 @@ export function useChartBoard({
|
||||
if (next?.kind === "house") onNeedLayer?.("ashtakavarga");
|
||||
}, [onNeedLayer]);
|
||||
|
||||
const setPointLayer = useCallback((next: PointGroup | "none") => {
|
||||
setPointLayerState(next);
|
||||
if (next !== "none") {
|
||||
setTypeState("D1");
|
||||
onNeedLayer?.("points");
|
||||
}
|
||||
}, [onNeedLayer]);
|
||||
|
||||
const step = useCallback((direction: 1 | -1) => {
|
||||
if (!model) return;
|
||||
setSelection((current) => stepSelection(model, current, direction));
|
||||
}, [model]);
|
||||
setSelection((current) => {
|
||||
if (current?.kind !== "point") return stepSelection(model, current, direction);
|
||||
const next = stepPoint(view, current.group, current.key, direction);
|
||||
return next ? pointSelection(next) : current;
|
||||
});
|
||||
}, [model, view]);
|
||||
|
||||
/** Point tags on the board: the whole layer, plus a point picked from a table. */
|
||||
const pointMarks = useMemo(() => {
|
||||
if (type !== "D1") return [];
|
||||
const layerRows = pointLayer === "none" ? [] : pointsOf(view, pointLayer) ?? [];
|
||||
const picked = selection?.kind === "point" && !layerRows.some((row) => row.key === selection.key)
|
||||
? (pointsOf(view, selection.group) ?? []).filter((row) => row.key === selection.key)
|
||||
: [];
|
||||
return [...layerRows, ...picked].map((row) => ({ key: row.key, short: row.short, label: row.label, sign: pointSelection(row).sign }));
|
||||
}, [pointLayer, selection, type, view]);
|
||||
|
||||
const selectPoint = useCallback((key: string) => {
|
||||
const group = pointLayer !== "none" && pointsOf(view, pointLayer)?.some((row) => row.key === key)
|
||||
? pointLayer
|
||||
: selection?.kind === "point" ? selection.group : null;
|
||||
const row = group ? pointsOf(view, group)?.find((item) => item.key === key) : null;
|
||||
if (!row) return;
|
||||
setTypeState("D1");
|
||||
setYogaBodies(null);
|
||||
setSelection((current) => (current?.kind === "point" && current.key === key ? null : pointSelection(row)));
|
||||
}, [pointLayer, selection, view]);
|
||||
|
||||
/** A row in the 虚星 / 特殊点 / 映射 tables; null clears. */
|
||||
const pickPoint = useCallback((point: ChartViewPoint | null) => {
|
||||
setYogaBodies(null);
|
||||
if (!point) { setSelection(null); return; }
|
||||
setTypeState("D1");
|
||||
setSelection(pointSelection(point));
|
||||
}, []);
|
||||
|
||||
const focusYoga = useCallback((bodies: readonly PlanetGlyphKey[] | null) => {
|
||||
setSelection(null);
|
||||
@@ -128,6 +179,13 @@ export function useChartBoard({
|
||||
yogaBodies,
|
||||
focusYoga,
|
||||
dimmedBodies,
|
||||
pointLayer,
|
||||
setPointLayer,
|
||||
pointMarks,
|
||||
selectPoint,
|
||||
pickPoint,
|
||||
pointsPending: pendingLayers?.has("points") ?? false,
|
||||
pointsFailure: layerFailures?.get("points"),
|
||||
/** The board or inspector shows a transit: fetch the observation day's sky. */
|
||||
needsTransit: layer === "transit" || selection?.kind === "body",
|
||||
typePending: typeLayer ? pendingLayers?.has(typeLayer) ?? false : false,
|
||||
|
||||
@@ -52,6 +52,7 @@ function mergeLayer(current: ChartViewOk, incoming: ChartViewOk, layer: ChartVie
|
||||
...(layer === "bhava" ? { bhava: incoming.bhava } : {}),
|
||||
...(layer === "transit" ? { transit: incoming.transit } : {}),
|
||||
...(layer === "ashtakavarga" ? { ashtakavarga: incoming.ashtakavarga } : {}),
|
||||
...(layer === "points" ? { points: incoming.points } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,6 +147,7 @@ export function useChartPage() {
|
||||
: dashaSystem ? body.dasha[dashaSystem.track].periods.length === 0
|
||||
: layer === "western" || layer === "bhava" || layer === "transit" ? body[layer].status !== "ok"
|
||||
: layer === "ashtakavarga" ? body.ashtakavarga?.status !== "ok"
|
||||
: layer === "points" ? body.points?.status !== "ok"
|
||||
: true;
|
||||
if (missing) { fail("unavailable"); return; }
|
||||
const current = peekChartPage();
|
||||
|
||||
@@ -52,6 +52,8 @@ export type BoardModel = {
|
||||
export type BoardSelection =
|
||||
| { kind: "body"; key: PlanetGlyphKey }
|
||||
| { kind: "house"; house: number }
|
||||
/** A point from the data tables (issue #114): upagraha, special lagna or arudha pada. */
|
||||
| { kind: "point"; key: string; group: "upagraha" | "special" | "arudha"; sign: number; sourceHouse: number | null }
|
||||
| null;
|
||||
|
||||
export function signIndex(sign: string): number {
|
||||
@@ -128,6 +130,13 @@ export type BoardRelations = {
|
||||
*/
|
||||
export function boardRelations(model: BoardModel, selection: BoardSelection): BoardRelations {
|
||||
if (!selection) return { focus: null, lordLinks: [], aspectLinks: [], lit: new Set() };
|
||||
if (selection.kind === "point") {
|
||||
// A point sits in the house holding its sign; an arudha pada links back to its own house.
|
||||
const focus = model.houses.find((house) => house.sign === selection.sign)?.house ?? null;
|
||||
if (focus === null) return { focus: null, lordLinks: [], aspectLinks: [], lit: new Set() };
|
||||
const aspectLinks = selection.sourceHouse !== null && selection.sourceHouse !== focus ? [selection.sourceHouse] : [];
|
||||
return { focus, lordLinks: [], aspectLinks, lit: new Set([focus, ...aspectLinks]) };
|
||||
}
|
||||
if (selection.kind === "body") {
|
||||
const focus = bodyHouse(model, selection.key);
|
||||
if (focus === null) return { focus: null, lordLinks: [], aspectLinks: [], lit: new Set() };
|
||||
@@ -147,7 +156,7 @@ export function boardRelations(model: BoardModel, selection: BoardSelection): Bo
|
||||
|
||||
/** Next body (or house) for the ← / → keys, in table order. */
|
||||
export function stepSelection(model: BoardModel, selection: BoardSelection, direction: 1 | -1): BoardSelection {
|
||||
if (!selection) return null;
|
||||
if (!selection || selection.kind === "point") return selection;
|
||||
if (selection.kind === "house") return { kind: "house", house: wrapHouse(selection.house + direction) };
|
||||
const present = PLANET_GLYPH_KEYS.filter((key) => bodyHouse(model, key) !== null);
|
||||
const index = present.indexOf(selection.key);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { boardFromChart, signIndex, type BoardModel } from "./chart-board-model.
|
||||
import type {
|
||||
ChartViewOk,
|
||||
ChartViewPlanetRow,
|
||||
ChartViewPoint,
|
||||
ChartViewShadbalaRow,
|
||||
ChartViewTransitOk,
|
||||
NorthIndianChart,
|
||||
@@ -189,3 +190,42 @@ export function signLabelAt(sign: number): string {
|
||||
export function chartToday(view: ChartViewOk): string {
|
||||
return view.dasha.asOf;
|
||||
}
|
||||
|
||||
export type PointGroup = ChartViewPoint["group"];
|
||||
|
||||
/** Rows of one point group, or null while the points layer is missing. */
|
||||
export function pointsOf(view: ChartViewOk, group: PointGroup): ChartViewPoint[] | null {
|
||||
if (view.points?.status !== "ok") return null;
|
||||
if (group === "upagraha") return view.points.upagrahas;
|
||||
if (group === "special") return view.points.special;
|
||||
return view.points.arudha;
|
||||
}
|
||||
|
||||
export function pointByKey(view: ChartViewOk, group: PointGroup, key: string): ChartViewPoint | null {
|
||||
return pointsOf(view, group)?.find((point) => point.key === key) ?? null;
|
||||
}
|
||||
|
||||
/** The board selection for a point row. */
|
||||
export function pointSelection(point: ChartViewPoint) {
|
||||
return {
|
||||
kind: "point" as const,
|
||||
key: point.key,
|
||||
group: point.group,
|
||||
sign: signIndex(point.sign),
|
||||
sourceHouse: point.sourceHouse,
|
||||
};
|
||||
}
|
||||
|
||||
/** Next / previous point inside the same table. */
|
||||
export function stepPoint(view: ChartViewOk, group: PointGroup, key: string, direction: 1 | -1): ChartViewPoint | null {
|
||||
const rows = pointsOf(view, group);
|
||||
if (!rows || rows.length === 0) return null;
|
||||
const index = rows.findIndex((point) => point.key === key);
|
||||
return rows[(Math.max(0, index) + direction + rows.length) % rows.length] ?? null;
|
||||
}
|
||||
|
||||
export const POINT_GROUP_ZH: Readonly<Record<PointGroup, string>> = {
|
||||
upagraha: "虚星",
|
||||
special: "特殊点",
|
||||
arudha: "映射",
|
||||
};
|
||||
|
||||
@@ -263,6 +263,13 @@ export const chartViewShadbalaRowSchema = z.object({
|
||||
required: z.number().positive(),
|
||||
percent: z.number(),
|
||||
level: chartViewStrengthLevelSchema,
|
||||
/** Sub-components in virupas (issue #114 数据表 · 星力明细); null on older packets. */
|
||||
sthanaParts: z.object({
|
||||
uccha: z.number(), saptavarga: z.number(), ojayugma: z.number(), kendra: z.number(), drekkana: z.number(),
|
||||
}).nullable().optional(),
|
||||
kalaParts: z.object({
|
||||
nathonnata: z.number(), paksha: z.number(), tribhaga: z.number(), ayana: z.number(), hora: z.number(),
|
||||
}).nullable().optional(),
|
||||
parts: z.object({
|
||||
sthana: z.number(),
|
||||
dig: z.number(),
|
||||
@@ -301,6 +308,50 @@ export const chartViewBirthCalendarSchema = z.object({
|
||||
weekday: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* Lazy layer `points` (`/api/chart_points`, issue #114 数据表): upagrahas,
|
||||
* special lagnas with the Yogi points, arudha padas and the friendship table.
|
||||
* Each group may be unavailable on its own.
|
||||
*/
|
||||
export const chartViewPointSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
group: z.enum(["upagraha", "special", "arudha"]),
|
||||
label: z.string().min(1),
|
||||
short: z.string().min(1),
|
||||
english: z.string().min(1),
|
||||
sign: z.string().min(1),
|
||||
signLabel: z.string().min(1),
|
||||
degreeInSign: z.number().nullable(),
|
||||
house: z.number().int().min(1).max(12),
|
||||
nakshatra: z.string().nullable(),
|
||||
pada: z.number().int().min(1).max(4).nullable(),
|
||||
nakshatraLord: z.string().nullable(),
|
||||
/** Arudha only: the house it is the pada of, and that house's lord. */
|
||||
sourceHouse: z.number().int().min(1).max(12).nullable(),
|
||||
sourceLord: z.string().nullable(),
|
||||
note: z.string().min(1),
|
||||
});
|
||||
export const chartViewFriendshipSchema = z.object({
|
||||
/** Planet → target → relation; natural and temporary −1/0/1, compound −2…2. */
|
||||
rows: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
targets: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
natural: z.number().int().min(-1).max(1),
|
||||
temporary: z.number().int().min(-1).max(1),
|
||||
compound: z.number().int().min(-2).max(2),
|
||||
})),
|
||||
})),
|
||||
});
|
||||
export const chartViewPointsOkSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
upagrahas: z.array(chartViewPointSchema).nullable(),
|
||||
special: z.array(chartViewPointSchema).nullable(),
|
||||
arudha: z.array(chartViewPointSchema).nullable(),
|
||||
friendship: chartViewFriendshipSchema.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Lazy layer `ashtakavarga`: `/api/ashtakavarga` and `/api/bhava_bala`. Every
|
||||
* twelve-slot array is indexed by sign (0 = Aries), as the engine returns it.
|
||||
@@ -315,6 +366,8 @@ export const chartViewAshtakavargaOkSchema = z.object({
|
||||
trikona: z.array(z.number().int()).length(12),
|
||||
sodhita: z.array(z.number().int()).length(12),
|
||||
})),
|
||||
/** The ascendant's own BAV row (SAV totals do not include it). */
|
||||
lagnaBav: z.array(z.number().int()).length(12).nullable().optional(),
|
||||
bhavaBala: z.array(z.object({
|
||||
house: z.number().int().min(1).max(12),
|
||||
sign: z.string().min(1),
|
||||
@@ -378,6 +431,7 @@ export const chartViewOkSchema = z.object({
|
||||
bhava: chartViewFailureStatusSchema.optional(),
|
||||
transit: chartViewFailureStatusSchema.optional(),
|
||||
ashtakavarga: chartViewFailureStatusSchema.optional(),
|
||||
points: chartViewFailureStatusSchema.optional(),
|
||||
}).optional(),
|
||||
lagnaPrecheck: chartViewLagnaPrecheckSchema.optional(),
|
||||
status: z.literal("ok"),
|
||||
@@ -408,6 +462,7 @@ export const chartViewOkSchema = z.object({
|
||||
birthCalendar: chartViewBirthCalendarSchema.nullable().optional(),
|
||||
yogas: chartViewYogasSchema.optional(),
|
||||
ashtakavarga: z.union([chartViewAshtakavargaOkSchema, chartViewUnavailableLayerSchema]).optional(),
|
||||
points: z.union([chartViewPointsOkSchema, chartViewUnavailableLayerSchema]).optional(),
|
||||
accountId: z.string().min(1).optional(),
|
||||
profileFingerprint: z.string().min(1).optional(),
|
||||
subjectId: z.string().min(1).optional(),
|
||||
@@ -448,6 +503,8 @@ export type ChartViewStrengthLevel = z.infer<typeof chartViewStrengthLevelSchema
|
||||
export type ChartViewYogas = z.infer<typeof chartViewYogasSchema>;
|
||||
export type ChartViewBirthCalendar = z.infer<typeof chartViewBirthCalendarSchema>;
|
||||
export type ChartViewAshtakavargaOk = z.infer<typeof chartViewAshtakavargaOkSchema>;
|
||||
export type ChartViewPointsOk = z.infer<typeof chartViewPointsOkSchema>;
|
||||
export type ChartViewPoint = z.infer<typeof chartViewPointSchema>;
|
||||
|
||||
export const CHART_VIEW_TABS = [
|
||||
{ id: "vedic", label: "星盘" },
|
||||
|
||||
@@ -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", "yogini", "western", "bhava", "transit", "ashtakavarga"] as const;
|
||||
export const CHART_VIEW_LAYERS = ["varga", "chara", "yogini", "western", "bhava", "transit", "ashtakavarga", "points"] as const;
|
||||
export type ChartViewLayer = (typeof CHART_VIEW_LAYERS)[number];
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
SIGN_ORDER,
|
||||
isSignName,
|
||||
planetZh,
|
||||
signZh,
|
||||
} from "./chart-view-labels.ts";
|
||||
import { panchangaNameZh } from "./panchanga-names-zh.ts";
|
||||
import type {
|
||||
ChartViewPoint,
|
||||
ChartViewPointsOk,
|
||||
ChartViewAshtakavargaOk,
|
||||
ChartViewBirthCalendar,
|
||||
ChartViewShadbalaRow,
|
||||
@@ -59,6 +62,32 @@ function shadbalaParts(row: Record<string, unknown>): ChartViewShadbalaRow["part
|
||||
return parts as NonNullable<ChartViewShadbalaRow["parts"]>;
|
||||
}
|
||||
|
||||
function sthanaParts(row: Record<string, unknown>): ChartViewShadbalaRow["sthanaParts"] {
|
||||
const sthana = record(row.sthana_bala);
|
||||
if (!sthana) return null;
|
||||
const parts = {
|
||||
uccha: finite(sthana.ucha_bala),
|
||||
saptavarga: finite(sthana.sapta_score),
|
||||
ojayugma: finite(sthana.ojayugma_bala),
|
||||
kendra: finite(sthana.kendra_bala),
|
||||
drekkana: finite(sthana.drekkana_bala),
|
||||
};
|
||||
return Object.values(parts).some((value) => value === null) ? null : parts as NonNullable<ChartViewShadbalaRow["sthanaParts"]>;
|
||||
}
|
||||
|
||||
function kalaParts(row: Record<string, unknown>): ChartViewShadbalaRow["kalaParts"] {
|
||||
const kala = record(row.kala_bala);
|
||||
if (!kala) return null;
|
||||
const parts = {
|
||||
nathonnata: finite(kala.nathonnata),
|
||||
paksha: finite(kala.paksha),
|
||||
tribhaga: finite(kala.tribhaga),
|
||||
ayana: finite(kala.ayana),
|
||||
hora: finite(kala.hora),
|
||||
};
|
||||
return Object.values(parts).some((value) => value === null) ? null : parts as NonNullable<ChartViewShadbalaRow["kalaParts"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seven rows in PLANET_ORDER. `modules.shadbala.planets` carries the
|
||||
* components; `shadbala` (the summary) carries the same totals and the BPHS
|
||||
@@ -82,6 +111,8 @@ export function mapShadbala(chart: Record<string, unknown>): ChartViewShadbalaRo
|
||||
percent,
|
||||
level: shadbalaLevel(percent),
|
||||
parts: detail ? shadbalaParts(detail) : null,
|
||||
sthanaParts: detail ? sthanaParts(detail) : null,
|
||||
kalaParts: detail ? kalaParts(detail) : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -196,6 +227,7 @@ export function mapAshtakavarga(
|
||||
return {
|
||||
status: "ok",
|
||||
sav: sav as number[],
|
||||
lagnaBav: twelve(record(rawRows.Lagna)?.bindus),
|
||||
bav,
|
||||
bhavaBala: bhava,
|
||||
bhavaBalaUnit: text(record(bhavaBala?.result)?.score_unit) ?? "Virupas",
|
||||
@@ -218,3 +250,148 @@ export function mapBirthCalendar(chart: Record<string, unknown>, date: string):
|
||||
weekday: WEEKDAY_ZH[new Date(Date.UTC(year, month - 1, day)).getUTCDay()]!,
|
||||
};
|
||||
}
|
||||
|
||||
/** Names, board labels and one-line traditional meanings for the points layer (issue #114). */
|
||||
const POINT_NAMES: Readonly<Record<string, { label: string; short: string; english: string; note: string }>> = {
|
||||
Dhuma: { label: "烟", short: "烟", english: "Dhuma", note: "传统上指耗散、看不清的阻碍" },
|
||||
Vyatipata: { label: "毁", short: "毁", english: "Vyatipata", note: "传统上指突变与意外" },
|
||||
Parivesha: { label: "晕", short: "晕", english: "Parivesha", note: "传统上指被遮蔽、受围困" },
|
||||
Indrachapa: { label: "虹", short: "虹", english: "Indrachapa", note: "传统上指虚饰、外表好看" },
|
||||
Upaketu: { label: "彗", short: "彗", english: "Upaketu", note: "传统上指骚扰与烦扰" },
|
||||
Kaala: { label: "时", short: "时", english: "Kaala", note: "太阳的时段点,传统上关乎时运" },
|
||||
Mrityu: { label: "死", short: "死", english: "Mrityu", note: "火星的时段点,传统上关乎消耗与终结" },
|
||||
Artha_Praharaka: { label: "利", short: "利", english: "Artha Praharaka", note: "水星的时段点,传统上关乎钱财的推动" },
|
||||
Yama_Ghantaka: { label: "阎", short: "阎", english: "Yama Ghantaka", note: "木星的时段点,传统上关乎拖延与警示" },
|
||||
Gulika: { label: "古利伽", short: "古", english: "Gulika", note: "土星的时段点,传统上关乎阻滞与业力负担" },
|
||||
Maandi: { label: "曼迪", short: "曼", english: "Maandi", note: "土星时段的另一种取法,与古利伽同类" },
|
||||
Bhava_Lagna: { label: "宫上升", short: "宫升", english: "Bhava Lagna", note: "从日出起按时间推出的上升,看自身的另一面" },
|
||||
Hora_Lagna: { label: "时上升", short: "时升", english: "Hora Lagna", note: "传统上看财富" },
|
||||
Ghati_Lagna: { label: "刻上升", short: "刻升", english: "Ghati Lagna", note: "传统上看权位与名声" },
|
||||
ViGhati_Lagna: { label: "细刻上升", short: "细升", english: "Vighati Lagna", note: "更细的时间点,常用来核对出生时间" },
|
||||
Pranapada_Lagna: { label: "生气点", short: "气", english: "Pranapada", note: "传统上看生命力,也用来核对出生时间" },
|
||||
Sree_Lagna: { label: "吉祥上升", short: "吉升", english: "Sree Lagna", note: "传统上看福运" },
|
||||
Indu_Lagna: { label: "财富月点", short: "财", english: "Indu Lagna", note: "传统上看财源" },
|
||||
Varnada_Lagna: { label: "社会上升", short: "社", english: "Varnada Lagna", note: "传统上看社会角色与职业类别" },
|
||||
Yogi_Point: { label: "瑜伽点", short: "瑜", english: "Yogi Point", note: "传统上的助力点" },
|
||||
Avayogi_Point: { label: "阻碍点", short: "阻", english: "Avayogi Point", note: "传统上的阻碍点" },
|
||||
};
|
||||
const ARUDHA_NOTES = [
|
||||
"外在形象,别人眼里的你", "财富看起来的样子", "勇气与同辈", "居所与家", "子女与名声", "对手与纠纷",
|
||||
"伴侣在外人眼里的样子", "隐秘与变故", "福德与师长", "事业在外的形象", "收益与人脉", "婚姻(Upapada)",
|
||||
] as const;
|
||||
|
||||
function pointRow(raw: unknown, group: ChartViewPoint["group"]): ChartViewPoint | null {
|
||||
const row = record(raw);
|
||||
const key = text(row?.key);
|
||||
const sign = text(row?.sign);
|
||||
const house = finite(row?.house);
|
||||
if (!row || !key || !sign || !isSignName(sign) || house === null) return null;
|
||||
const names = POINT_NAMES[key];
|
||||
const pada = finite(row.nakshatra_pada);
|
||||
return {
|
||||
key,
|
||||
group,
|
||||
label: names?.label ?? key,
|
||||
short: names?.short ?? key,
|
||||
english: names?.english ?? key.replace(/_/g, " "),
|
||||
sign,
|
||||
signLabel: signZh(sign),
|
||||
degreeInSign: finite(row.degree_in_sign),
|
||||
house,
|
||||
nakshatra: text(row.nakshatra),
|
||||
pada: pada !== null && pada >= 1 && pada <= 4 ? Math.trunc(pada) : null,
|
||||
nakshatraLord: text(row.nakshatra_lord) ? planetZh(text(row.nakshatra_lord)!) : null,
|
||||
sourceHouse: null,
|
||||
sourceLord: null,
|
||||
note: names?.note ?? "—",
|
||||
};
|
||||
}
|
||||
|
||||
function pointRows(block: unknown, group: ChartViewPoint["group"]): ChartViewPoint[] | null {
|
||||
const packet = record(block);
|
||||
if (!packet || packet.status !== "computed" || !Array.isArray(packet.rows)) return null;
|
||||
const rows = packet.rows.map((row) => pointRow(row, group)).filter((row): row is ChartViewPoint => row !== null);
|
||||
return rows.length > 0 ? rows : null;
|
||||
}
|
||||
|
||||
function arudhaRows(block: unknown): ChartViewPoint[] | null {
|
||||
const packet = record(block);
|
||||
if (!packet || packet.status !== "computed" || !Array.isArray(packet.rows)) return null;
|
||||
const rows = packet.rows.flatMap((raw, index): ChartViewPoint[] => {
|
||||
const row = record(raw);
|
||||
const key = text(row?.key);
|
||||
const sign = text(row?.sign);
|
||||
const house = finite(row?.house);
|
||||
const source = finite(row?.source_house);
|
||||
if (!row || !key || !sign || !isSignName(sign) || house === null) return [];
|
||||
const short = key === "A1" ? "AL" : key;
|
||||
return [{
|
||||
key,
|
||||
group: "arudha",
|
||||
label: key === "A1" ? "AL(A1)" : key === "UL" ? "UL(A12)" : key,
|
||||
short,
|
||||
english: key === "A1" ? "Arudha Lagna" : key === "UL" ? "Upapada" : `Arudha ${key.slice(1)}`,
|
||||
sign,
|
||||
signLabel: signZh(sign),
|
||||
degreeInSign: null,
|
||||
house,
|
||||
nakshatra: null,
|
||||
pada: null,
|
||||
nakshatraLord: null,
|
||||
sourceHouse: source !== null && source >= 1 && source <= 12 ? Math.trunc(source) : index + 1,
|
||||
sourceLord: text(row.source_lord) ? planetZh(text(row.source_lord)!) : null,
|
||||
note: ARUDHA_NOTES[index] ?? "—",
|
||||
}];
|
||||
});
|
||||
return rows.length > 0 ? rows : null;
|
||||
}
|
||||
|
||||
const COMPOUND_SCORE: Readonly<Record<string, number>> = {
|
||||
great_friends: 2, friends: 1, neutral: 0, enemies: -1, great_enemies: -2,
|
||||
};
|
||||
|
||||
function mapFriendship(block: unknown): ChartViewPointsOk["friendship"] {
|
||||
const packet = record(block);
|
||||
if (!packet || !Array.isArray(packet.rows)) return null;
|
||||
const rows = packet.rows.flatMap((raw) => {
|
||||
const row = record(raw);
|
||||
const name = text(row?.planet);
|
||||
if (!row || !name) return [];
|
||||
const natural = record(row.natural) ?? {};
|
||||
const temporary = record(row.temporary) ?? {};
|
||||
const compound = record(row.compound) ?? {};
|
||||
const has = (group: Record<string, unknown>, key: string, target: string) => Array.isArray(group[key]) && (group[key] as unknown[]).includes(target);
|
||||
const targets = PLANET_ORDER.filter((target) => target !== name).flatMap((target) => {
|
||||
const compoundKey = Object.keys(COMPOUND_SCORE).find((key) => has(compound, key, target));
|
||||
if (!compoundKey) return [];
|
||||
return [{
|
||||
name: target,
|
||||
natural: has(natural, "friends", target) ? 1 : has(natural, "enemies", target) ? -1 : 0,
|
||||
temporary: has(temporary, "friends", target) ? 1 : -1,
|
||||
compound: COMPOUND_SCORE[compoundKey]!,
|
||||
}];
|
||||
});
|
||||
return [{ name, label: planetZh(name), targets }];
|
||||
});
|
||||
return rows.length > 0 ? { rows } : null;
|
||||
}
|
||||
|
||||
export function unavailablePoints(reason: string): ChartViewUnavailableLayer {
|
||||
return { status: "unavailable", reason, copy: "这几张表暂时没算出来,稍后再试。" };
|
||||
}
|
||||
|
||||
/** `/api/chart_points` → the points layer. A missing group is null, never filled in. */
|
||||
export function mapPoints(packet: Record<string, unknown> | null): ChartViewPointsOk | ChartViewUnavailableLayer {
|
||||
if (!packet || packet.success === false) return unavailablePoints("points_missing");
|
||||
const special = pointRows(packet.special_lagnas, "special");
|
||||
const yogi = pointRows(packet.yogi_points, "special");
|
||||
const layer = {
|
||||
status: "ok" as const,
|
||||
upagrahas: pointRows(packet.upagrahas, "upagraha"),
|
||||
special: special || yogi ? [...(special ?? []), ...(yogi ?? [])] : null,
|
||||
arudha: arudhaRows(packet.arudha_padas),
|
||||
friendship: mapFriendship(packet.friendship),
|
||||
};
|
||||
if (!layer.upagrahas && !layer.special && !layer.arudha && !layer.friendship) return unavailablePoints("points_all_blocked");
|
||||
return layer;
|
||||
}
|
||||
|
||||
@@ -252,6 +252,10 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
bhavaBala: enginePayload(houses),
|
||||
}))
|
||||
: null;
|
||||
// Upagrahas need the birth moment and place; followUp carries both.
|
||||
const points = layers.has("points")
|
||||
? await callEngine(input.postEngine, "/api/chart_points", followUp).then(layerPayload("points"))
|
||||
: null;
|
||||
await dashaReady;
|
||||
const lagnaPrecheck = await lagnaReady;
|
||||
|
||||
@@ -265,6 +269,7 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
bhava,
|
||||
transit,
|
||||
transitDate,
|
||||
points,
|
||||
ashtakavarga,
|
||||
profile,
|
||||
asOf: input.asOf,
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
} from "./chart-view-contract.ts";
|
||||
import { asRecord as record } from "@/lib/guards";
|
||||
import { addWeightedSpan } from "./dasha-span.ts";
|
||||
import { mapAshtakavarga, mapBirthCalendar, mapShadbala, mapYogas } from "./chart-view-insight.ts";
|
||||
import { mapAshtakavarga, mapBirthCalendar, mapPoints, mapShadbala, mapYogas } from "./chart-view-insight.ts";
|
||||
|
||||
export type ChartViewProfileInput = {
|
||||
name?: string;
|
||||
@@ -73,6 +73,8 @@ export type ChartViewEngineBundle = {
|
||||
/** Lazy layer `ashtakavarga`: both packets, or null when it was not asked for. */
|
||||
/** Day the transit packet was computed for; `asOf` when absent. */
|
||||
transitDate?: string;
|
||||
/** Lazy layer `points`: `/api/chart_points`, or null when it was not asked for. */
|
||||
points?: Record<string, unknown> | null;
|
||||
ashtakavarga?: { ashtakavarga: Record<string, unknown> | null; bhavaBala: Record<string, unknown> | null } | null;
|
||||
profile: ChartViewProfileInput;
|
||||
asOf: string;
|
||||
@@ -890,7 +892,7 @@ export function mapLagnaPrecheck(window: LagnaPrecheckWindow, payload: unknown):
|
||||
}
|
||||
|
||||
export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
const { chart, varga, chara, yogini = null, western, bhava, transit, transitDate, ashtakavarga = null, profile, asOf, lagnaPrecheck = null } = bundle;
|
||||
const { chart, varga, chara, yogini = null, western, bhava, transit, transitDate, points = null, ashtakavarga = null, profile, asOf, lagnaPrecheck = null } = bundle;
|
||||
const planets = asPlanetMap(chart.planets);
|
||||
const moon = planets.Moon ?? {};
|
||||
const ascendant = record(chart.ascendant);
|
||||
@@ -952,6 +954,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk {
|
||||
shadbala: mapShadbala(chart),
|
||||
yogas: mapYogas(chart),
|
||||
...(ashtakavarga ? { ashtakavarga: mapAshtakavarga(ashtakavarga.ashtakavarga, ashtakavarga.bhavaBala) } : {}),
|
||||
...(points ? { points: mapPoints(points) } : {}),
|
||||
};
|
||||
return chartViewOkSchema.parse(view);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ import { readStoredChartBoardStyle, writeStoredChartBoardStyle } from "../src/li
|
||||
import { assembleChartView } from "../src/lib/chart-view-load.ts";
|
||||
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||||
import { CHART_VIEW_LAYERS, parseTransitDate } from "../src/lib/chart-view-engine.ts";
|
||||
import { mapAshtakavarga, mapBirthCalendar, mapShadbala, mapYogas, shadbalaLevel } from "../src/lib/chart-view-insight.ts";
|
||||
import { mapAshtakavarga, mapBirthCalendar, mapPoints, mapShadbala, mapYogas, shadbalaLevel } from "../src/lib/chart-view-insight.ts";
|
||||
import { pointSelection, pointsOf, stepPoint } from "../src/lib/chart-board-view.ts";
|
||||
import { vimshottariSplit } from "../src/lib/dasha-span.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
@@ -36,6 +37,7 @@ const insight = JSON.parse(readFileSync(new URL("./fixtures/chart-insight-golden
|
||||
chart_modules_shadbala: Record<string, unknown>;
|
||||
ashtakavarga: Record<string, unknown>;
|
||||
bhava_bala: Record<string, unknown>;
|
||||
chart_points: Record<string, unknown>;
|
||||
second_case: {
|
||||
yogas: unknown[];
|
||||
vimshottari_current: { antardasha: { lord: string; start: string; end: string } };
|
||||
@@ -68,6 +70,7 @@ async function okView(): Promise<ChartViewOk> {
|
||||
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
|
||||
if (path === "/api/ashtakavarga") return { status: "ok", payload: insight.ashtakavarga };
|
||||
if (path === "/api/bhava_bala") return { status: "ok", payload: insight.bhava_bala };
|
||||
if (path === "/api/chart_points") return { status: "ok", payload: insight.chart_points };
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
},
|
||||
});
|
||||
@@ -269,3 +272,59 @@ test("the board stylesheet stays off the first paint of the chat page", () => {
|
||||
assert.doesNotMatch(page, /chart-board/);
|
||||
assert.match(route, /import "@\/components\/chart-board\/chart-board-styles";/);
|
||||
});
|
||||
|
||||
test("the points layer keeps the engine's upagrahas, special lagnas, arudha padas and friendship", () => {
|
||||
const layer = mapPoints(insight.chart_points);
|
||||
assert.equal(layer.status, "ok");
|
||||
if (layer.status !== "ok") return;
|
||||
assert.equal(layer.upagrahas?.length, 11);
|
||||
assert.deepEqual(layer.upagrahas!.slice(0, 5).map((point) => point.short), ["烟", "毁", "晕", "虹", "彗"]);
|
||||
assert.equal(layer.special?.length, 10, "eight special lagnas plus Yogi and Avayogi");
|
||||
assert.ok(layer.special!.every((point) => point.nakshatra && point.pada));
|
||||
assert.deepEqual(layer.arudha!.map((point) => point.short), ["AL", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL"]);
|
||||
assert.ok(layer.arudha!.every((point, index) => point.sourceHouse === index + 1));
|
||||
const sun = layer.friendship!.rows.find((row) => row.name === "Sun")!;
|
||||
for (const target of sun.targets) {
|
||||
if (target.compound === 2) assert.ok(target.natural === 1 && target.temporary === 1);
|
||||
if (target.compound === -2) assert.ok(target.natural === -1 && target.temporary === -1);
|
||||
}
|
||||
assert.equal(mapPoints(null).status, "unavailable");
|
||||
});
|
||||
|
||||
test("the ascendant's own BAV row and the shadbala sub-components come through", () => {
|
||||
const layer = mapAshtakavarga(insight.ashtakavarga, insight.bhava_bala);
|
||||
assert.equal(layer.status === "ok" && layer.lagnaBav?.reduce((a, b) => a + b, 0), 49);
|
||||
const sun = mapShadbala(insightChart).find((row) => row.name === "Sun")!;
|
||||
assert.ok(sun.sthanaParts && sun.kalaParts);
|
||||
assert.equal(sun.kalaParts!.nathonnata, 60);
|
||||
});
|
||||
|
||||
test("a picked arudha pada sits in its sign and links back to its own house", async () => {
|
||||
const view = await okView();
|
||||
const model = natalBoard(view);
|
||||
const ul = pointsOf(view, "arudha")!.find((point) => point.key === "UL")!;
|
||||
const relations = boardRelations(model, pointSelection(ul));
|
||||
assert.equal(relations.focus, ul.house);
|
||||
assert.deepEqual(relations.aspectLinks, ul.house === 12 ? [] : [12]);
|
||||
assert.equal(stepPoint(view, "arudha", "UL", 1)?.key, "A1");
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartBoardSvg, {
|
||||
model, style: "south", selection: pointSelection(ul), onSelect: () => {}, ariaLabel: "本命盘",
|
||||
pointMarks: pointsOf(view, "arudha")!.map((point) => ({ key: point.key, short: point.short, label: point.label, sign: pointSelection(point).sign })),
|
||||
}));
|
||||
assert.equal((markup.match(/class="chart-board-mark( is-selected)?"/g) ?? []).length, 12);
|
||||
assert.equal((markup.match(/chart-board-mark is-selected/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("the inspector explains a point and 问 AI writes a question about it", async () => {
|
||||
const view = await okView();
|
||||
const model = natalBoard(view);
|
||||
const gulika = pointsOf(view, "upagraha")!.find((point) => point.key === "Gulika")!;
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartInspector, {
|
||||
view, transit: null, model, source: boardSource(view, "D1")!,
|
||||
selection: pointSelection(gulika), onSelect: () => {}, onStep: () => {}, onAsk: () => {},
|
||||
}));
|
||||
assert.match(markup, /古利伽/);
|
||||
assert.match(markup, /传统含义/);
|
||||
assert.match(markup, /问 AI:这个点/);
|
||||
assert.match(inspectorQuestion(view, model, pointSelection(gulika)), /^我的古利伽(Gulika)在.+第\d+宫,说明什么?$/);
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ test("the four tabs render and can each be selected", async () => {
|
||||
// 原值:基础信息页含「本命」与「D9」
|
||||
// 新值:排盘底册的行星名册含「D9」一列和「宫主流向」
|
||||
// 原因:基础信息并进「排盘底册」(issue #114 M7),行星卡改成名册表
|
||||
assert.match(markup, /行星名册/);
|
||||
assert.match(markup, /数据表/);
|
||||
assert.match(markup, /D9/);
|
||||
assert.match(markup, /宫主流向/);
|
||||
}
|
||||
|
||||
+839
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"sourceCommand": "python3 scripts/jyotish_api_server.py --port 5291; POST /api/chart (profile of chart-view-golden.json: 1990-06-15 12:00 Beijing, raman, mean node), then /api/ashtakavarga and /api/bhava_bala with its planets/ascendant/houses. Only the fields the chart page reads are kept.",
|
||||
"sourceCommand": "python3 scripts/jyotish_api_server.py --port 5291; POST /api/chart (profile of chart-view-golden.json: 1990-06-15 12:00 Beijing, raman, mean node), then /api/ashtakavarga and /api/bhava_bala with its planets/ascendant/houses. Only the fields the chart page reads are kept. `chart_points`: scripts/chart_view_points/api.py compute_chart_points on the same planets/ascendant (what POST /api/chart_points returns).",
|
||||
"chart": {
|
||||
"ascendant": {
|
||||
"sign": "Leo",
|
||||
@@ -1125,5 +1125,843 @@
|
||||
"end": "2037-08-21"
|
||||
}
|
||||
]
|
||||
},
|
||||
"chart_points": {
|
||||
"success": true,
|
||||
"endpoint": "chart_points",
|
||||
"ascendant_sign": "Leo",
|
||||
"upagrahas": {
|
||||
"status": "computed",
|
||||
"rows": [
|
||||
{
|
||||
"nakshatra": "Swati",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Rahu",
|
||||
"key": "Dhuma",
|
||||
"longitude": 194.8635,
|
||||
"sign": "Libra",
|
||||
"degree_in_sign": 14.8635,
|
||||
"house": 3,
|
||||
"kind": "solar"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Hasta",
|
||||
"nakshatra_pada": 2,
|
||||
"nakshatra_lord": "Moon",
|
||||
"key": "Vyatipata",
|
||||
"longitude": 165.1365,
|
||||
"sign": "Virgo",
|
||||
"degree_in_sign": 15.1365,
|
||||
"house": 2,
|
||||
"kind": "solar"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Uttara Bhadrapada",
|
||||
"nakshatra_pada": 4,
|
||||
"nakshatra_lord": "Saturn",
|
||||
"key": "Parivesha",
|
||||
"longitude": 345.1365,
|
||||
"sign": "Pisces",
|
||||
"degree_in_sign": 15.1365,
|
||||
"house": 8,
|
||||
"kind": "solar"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Bharani",
|
||||
"nakshatra_pada": 1,
|
||||
"nakshatra_lord": "Venus",
|
||||
"key": "Indrachapa",
|
||||
"longitude": 14.8635,
|
||||
"sign": "Aries",
|
||||
"degree_in_sign": 14.8635,
|
||||
"house": 9,
|
||||
"kind": "solar"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Krittika",
|
||||
"nakshatra_pada": 2,
|
||||
"nakshatra_lord": "Sun",
|
||||
"key": "Upaketu",
|
||||
"longitude": 31.5302,
|
||||
"sign": "Taurus",
|
||||
"degree_in_sign": 1.5302,
|
||||
"house": 10,
|
||||
"kind": "solar"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Purva Phalguni",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Venus",
|
||||
"key": "Kaala",
|
||||
"longitude": 141.2324,
|
||||
"sign": "Leo",
|
||||
"degree_in_sign": 21.2324,
|
||||
"house": 1,
|
||||
"kind": "time_segment"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Chitra",
|
||||
"nakshatra_pada": 4,
|
||||
"nakshatra_lord": "Mars",
|
||||
"key": "Mrityu",
|
||||
"longitude": 185.654,
|
||||
"sign": "Libra",
|
||||
"degree_in_sign": 5.654,
|
||||
"house": 3,
|
||||
"kind": "time_segment"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Vishakha",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Jupiter",
|
||||
"key": "Artha_Praharaka",
|
||||
"longitude": 207.6025,
|
||||
"sign": "Libra",
|
||||
"degree_in_sign": 27.6025,
|
||||
"house": 3,
|
||||
"kind": "time_segment"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Jyeshtha",
|
||||
"nakshatra_pada": 1,
|
||||
"nakshatra_lord": "Mercury",
|
||||
"key": "Yama_Ghantaka",
|
||||
"longitude": 229.9658,
|
||||
"sign": "Scorpio",
|
||||
"degree_in_sign": 19.9658,
|
||||
"house": 4,
|
||||
"kind": "time_segment"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Punarvasu",
|
||||
"nakshatra_pada": 2,
|
||||
"nakshatra_lord": "Jupiter",
|
||||
"key": "Gulika",
|
||||
"longitude": 85.884,
|
||||
"sign": "Gemini",
|
||||
"degree_in_sign": 25.884,
|
||||
"house": 11,
|
||||
"kind": "time_segment"
|
||||
},
|
||||
{
|
||||
"nakshatra": "Pushya",
|
||||
"nakshatra_pada": 2,
|
||||
"nakshatra_lord": "Saturn",
|
||||
"key": "Maandi",
|
||||
"longitude": 97.1863,
|
||||
"sign": "Cancer",
|
||||
"degree_in_sign": 7.1863,
|
||||
"house": 12,
|
||||
"kind": "time_segment"
|
||||
}
|
||||
],
|
||||
"boundary": "Local D1 formulas are retained with segment evidence. Public PyJHora observations are used only for calibration; D-N Upagraha mapping and external numeric closure remain pending."
|
||||
},
|
||||
"special_lagnas": {
|
||||
"status": "computed",
|
||||
"rows": [
|
||||
{
|
||||
"nakshatra": "Hasta",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Moon",
|
||||
"key": "Bhava_Lagna",
|
||||
"longitude": 169.008,
|
||||
"sign": "Virgo",
|
||||
"degree_in_sign": 19.008,
|
||||
"house": 2
|
||||
},
|
||||
{
|
||||
"nakshatra": "Uttara Ashadha",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Sun",
|
||||
"key": "Hora_Lagna",
|
||||
"longitude": 276.4468,
|
||||
"sign": "Capricorn",
|
||||
"degree_in_sign": 6.4468,
|
||||
"house": 6
|
||||
},
|
||||
{
|
||||
"nakshatra": "Jyeshtha",
|
||||
"nakshatra_pada": 4,
|
||||
"nakshatra_lord": "Mercury",
|
||||
"key": "Ghati_Lagna",
|
||||
"longitude": 238.7634,
|
||||
"sign": "Scorpio",
|
||||
"degree_in_sign": 28.7634,
|
||||
"house": 4
|
||||
},
|
||||
{
|
||||
"nakshatra": "Krittika",
|
||||
"nakshatra_pada": 1,
|
||||
"nakshatra_lord": "Sun",
|
||||
"key": "ViGhati_Lagna",
|
||||
"longitude": 27.9005,
|
||||
"sign": "Aries",
|
||||
"degree_in_sign": 27.9005,
|
||||
"house": 9
|
||||
},
|
||||
{
|
||||
"nakshatra": "Hasta",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Moon",
|
||||
"key": "Pranapada_Lagna",
|
||||
"longitude": 168.0358,
|
||||
"sign": "Virgo",
|
||||
"degree_in_sign": 18.0358,
|
||||
"house": 2
|
||||
},
|
||||
{
|
||||
"nakshatra": "Ashlesha",
|
||||
"nakshatra_pada": 2,
|
||||
"nakshatra_lord": "Mercury",
|
||||
"key": "Sree_Lagna",
|
||||
"longitude": 112.7515,
|
||||
"sign": "Cancer",
|
||||
"degree_in_sign": 22.7515,
|
||||
"house": 12
|
||||
},
|
||||
{
|
||||
"nakshatra": "Ashlesha",
|
||||
"nakshatra_pada": 1,
|
||||
"nakshatra_lord": "Mercury",
|
||||
"key": "Indu_Lagna",
|
||||
"longitude": 108.6428,
|
||||
"sign": "Cancer",
|
||||
"degree_in_sign": 18.6428,
|
||||
"house": 12
|
||||
},
|
||||
{
|
||||
"nakshatra": "Mrigashira",
|
||||
"nakshatra_pada": 2,
|
||||
"nakshatra_lord": "Mars",
|
||||
"key": "Varnada_Lagna",
|
||||
"longitude": 59.3958,
|
||||
"sign": "Taurus",
|
||||
"degree_in_sign": 29.3958,
|
||||
"house": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
"yogi_points": {
|
||||
"status": "computed",
|
||||
"rows": [
|
||||
{
|
||||
"nakshatra": "Ashlesha",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Mercury",
|
||||
"key": "Yogi_Point",
|
||||
"longitude": 113.5061,
|
||||
"sign": "Cancer",
|
||||
"degree_in_sign": 23.5061,
|
||||
"house": 12
|
||||
},
|
||||
{
|
||||
"nakshatra": "Dhanishta",
|
||||
"nakshatra_pada": 3,
|
||||
"nakshatra_lord": "Mars",
|
||||
"key": "Avayogi_Point",
|
||||
"longitude": 300.1728,
|
||||
"sign": "Aquarius",
|
||||
"degree_in_sign": 0.1728,
|
||||
"house": 7
|
||||
}
|
||||
],
|
||||
"formula": "sidereal_sun_longitude + sidereal_moon_longitude + 93d20m; Ava Yogi = Yogi + 186d40m"
|
||||
},
|
||||
"arudha_padas": {
|
||||
"status": "computed",
|
||||
"rows": [
|
||||
{
|
||||
"key": "A1",
|
||||
"sign": "Aries",
|
||||
"house": 9,
|
||||
"source_house": 1,
|
||||
"source_lord": "Sun",
|
||||
"exception": false
|
||||
},
|
||||
{
|
||||
"key": "A2",
|
||||
"sign": "Capricorn",
|
||||
"house": 6,
|
||||
"source_house": 2,
|
||||
"source_lord": "Mercury",
|
||||
"exception": false
|
||||
},
|
||||
{
|
||||
"key": "A3",
|
||||
"sign": "Cancer",
|
||||
"house": 12,
|
||||
"source_house": 3,
|
||||
"source_lord": "Venus",
|
||||
"exception": true
|
||||
},
|
||||
{
|
||||
"key": "A4",
|
||||
"sign": "Cancer",
|
||||
"house": 12,
|
||||
"source_house": 4,
|
||||
"source_lord": "Mars",
|
||||
"exception": false
|
||||
},
|
||||
{
|
||||
"key": "A5",
|
||||
"sign": "Virgo",
|
||||
"house": 2,
|
||||
"source_house": 5,
|
||||
"source_lord": "Jupiter",
|
||||
"exception": true
|
||||
},
|
||||
{
|
||||
"key": "A6",
|
||||
"sign": "Libra",
|
||||
"house": 3,
|
||||
"source_house": 6,
|
||||
"source_lord": "Saturn",
|
||||
"exception": true
|
||||
},
|
||||
{
|
||||
"key": "A7",
|
||||
"sign": "Sagittarius",
|
||||
"house": 5,
|
||||
"source_house": 7,
|
||||
"source_lord": "Saturn",
|
||||
"exception": false
|
||||
},
|
||||
{
|
||||
"key": "A8",
|
||||
"sign": "Sagittarius",
|
||||
"house": 5,
|
||||
"source_house": 8,
|
||||
"source_lord": "Jupiter",
|
||||
"exception": true
|
||||
},
|
||||
{
|
||||
"key": "A9",
|
||||
"sign": "Aquarius",
|
||||
"house": 7,
|
||||
"source_house": 9,
|
||||
"source_lord": "Mars",
|
||||
"exception": false
|
||||
},
|
||||
{
|
||||
"key": "A10",
|
||||
"sign": "Pisces",
|
||||
"house": 8,
|
||||
"source_house": 10,
|
||||
"source_lord": "Venus",
|
||||
"exception": false
|
||||
},
|
||||
{
|
||||
"key": "A11",
|
||||
"sign": "Aries",
|
||||
"house": 9,
|
||||
"source_house": 11,
|
||||
"source_lord": "Mercury",
|
||||
"exception": false
|
||||
},
|
||||
{
|
||||
"key": "UL",
|
||||
"sign": "Virgo",
|
||||
"house": 2,
|
||||
"source_house": 12,
|
||||
"source_lord": "Moon",
|
||||
"exception": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"friendship": {
|
||||
"status": "parameter_sensitive",
|
||||
"method": "local_permanent_relationship_constants_plus_temporary_sign_distance",
|
||||
"source_planets": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
],
|
||||
"rows": [
|
||||
{
|
||||
"planet": "Sun",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Jupiter"
|
||||
],
|
||||
"enemies": [
|
||||
"Venus",
|
||||
"Saturn"
|
||||
],
|
||||
"neutral": [
|
||||
"Mercury",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Mars",
|
||||
"Mercury",
|
||||
"Venus",
|
||||
"Ketu"
|
||||
],
|
||||
"enemies": [
|
||||
"Moon",
|
||||
"Jupiter",
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Mars"
|
||||
],
|
||||
"friends": [
|
||||
"Mercury",
|
||||
"Ketu"
|
||||
],
|
||||
"neutral": [
|
||||
"Moon",
|
||||
"Jupiter",
|
||||
"Venus"
|
||||
],
|
||||
"enemies": [
|
||||
"Rahu"
|
||||
],
|
||||
"great_enemies": [
|
||||
"Saturn"
|
||||
]
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Moon",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Mercury"
|
||||
],
|
||||
"enemies": [],
|
||||
"neutral": [
|
||||
"Mars",
|
||||
"Jupiter",
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Mars",
|
||||
"Mercury",
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Jupiter",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Mercury"
|
||||
],
|
||||
"friends": [
|
||||
"Mars",
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
],
|
||||
"neutral": [
|
||||
"Sun"
|
||||
],
|
||||
"enemies": [
|
||||
"Jupiter",
|
||||
"Ketu"
|
||||
],
|
||||
"great_enemies": []
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Mars",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Jupiter"
|
||||
],
|
||||
"enemies": [
|
||||
"Mercury"
|
||||
],
|
||||
"neutral": [
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
],
|
||||
"enemies": [
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Jupiter"
|
||||
],
|
||||
"friends": [
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
],
|
||||
"neutral": [
|
||||
"Mercury"
|
||||
],
|
||||
"enemies": [
|
||||
"Ketu"
|
||||
],
|
||||
"great_enemies": []
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Mercury",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Venus"
|
||||
],
|
||||
"enemies": [
|
||||
"Moon"
|
||||
],
|
||||
"neutral": [
|
||||
"Mars",
|
||||
"Jupiter",
|
||||
"Saturn",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Jupiter",
|
||||
"Venus",
|
||||
"Ketu"
|
||||
],
|
||||
"enemies": [
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Sun",
|
||||
"Venus"
|
||||
],
|
||||
"friends": [
|
||||
"Mars",
|
||||
"Jupiter",
|
||||
"Ketu"
|
||||
],
|
||||
"neutral": [
|
||||
"Moon"
|
||||
],
|
||||
"enemies": [
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
],
|
||||
"great_enemies": []
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Jupiter",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mars"
|
||||
],
|
||||
"enemies": [
|
||||
"Mercury",
|
||||
"Venus"
|
||||
],
|
||||
"neutral": [
|
||||
"Saturn",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Mars",
|
||||
"Mercury",
|
||||
"Venus",
|
||||
"Ketu"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Mars"
|
||||
],
|
||||
"friends": [
|
||||
"Ketu"
|
||||
],
|
||||
"neutral": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mercury",
|
||||
"Venus"
|
||||
],
|
||||
"enemies": [
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
],
|
||||
"great_enemies": []
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Venus",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Mercury",
|
||||
"Saturn"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Moon"
|
||||
],
|
||||
"neutral": [
|
||||
"Mars",
|
||||
"Jupiter",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Saturn",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
],
|
||||
"enemies": []
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Mercury",
|
||||
"Saturn"
|
||||
],
|
||||
"friends": [
|
||||
"Mars",
|
||||
"Jupiter",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
],
|
||||
"neutral": [
|
||||
"Sun",
|
||||
"Moon"
|
||||
],
|
||||
"enemies": [],
|
||||
"great_enemies": []
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Saturn",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Mercury",
|
||||
"Venus"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mars"
|
||||
],
|
||||
"neutral": [
|
||||
"Jupiter",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Venus"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Venus"
|
||||
],
|
||||
"friends": [],
|
||||
"neutral": [
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Mercury"
|
||||
],
|
||||
"enemies": [
|
||||
"Jupiter",
|
||||
"Rahu",
|
||||
"Ketu"
|
||||
],
|
||||
"great_enemies": [
|
||||
"Sun"
|
||||
]
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Rahu",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Venus",
|
||||
"Saturn"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Jupiter"
|
||||
],
|
||||
"neutral": [
|
||||
"Mars",
|
||||
"Mercury",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Venus"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Saturn",
|
||||
"Ketu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [
|
||||
"Venus"
|
||||
],
|
||||
"friends": [
|
||||
"Mars"
|
||||
],
|
||||
"neutral": [
|
||||
"Moon",
|
||||
"Saturn"
|
||||
],
|
||||
"enemies": [
|
||||
"Mercury",
|
||||
"Ketu"
|
||||
],
|
||||
"great_enemies": [
|
||||
"Sun",
|
||||
"Jupiter"
|
||||
]
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
},
|
||||
{
|
||||
"planet": "Ketu",
|
||||
"natural": {
|
||||
"friends": [
|
||||
"Mars",
|
||||
"Saturn"
|
||||
],
|
||||
"enemies": [
|
||||
"Sun",
|
||||
"Moon"
|
||||
],
|
||||
"neutral": [
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Venus",
|
||||
"Rahu"
|
||||
]
|
||||
},
|
||||
"temporary": {
|
||||
"friends": [
|
||||
"Sun",
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Venus"
|
||||
],
|
||||
"enemies": [
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Saturn",
|
||||
"Rahu"
|
||||
]
|
||||
},
|
||||
"compound": {
|
||||
"great_friends": [],
|
||||
"friends": [
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Venus"
|
||||
],
|
||||
"neutral": [
|
||||
"Sun",
|
||||
"Mars",
|
||||
"Saturn"
|
||||
],
|
||||
"enemies": [
|
||||
"Rahu"
|
||||
],
|
||||
"great_enemies": [
|
||||
"Moon"
|
||||
]
|
||||
},
|
||||
"status": "parameter_sensitive"
|
||||
}
|
||||
],
|
||||
"boundary": "PL9 p41 node relationship rules and presentation categories are not yet field-level parity verified; this is a local raw relationship snapshot only."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""Chart-page data tables (issue #114): upagrahas, special lagnas, arudha padas, friendship."""
|
||||
@@ -0,0 +1,180 @@
|
||||
"""`/api/chart_points`: the chart page's data tables and point layers (issue #114).
|
||||
|
||||
Everything here is an existing engine producer called directly, so the chart
|
||||
page does not have to run the full reading:
|
||||
|
||||
- upagrahas: ``gulika.calculate_upagrahas`` (solar five + six time-segment points)
|
||||
- special lagnas: ``native_special_lagnas.native_special_lagna_rows`` (Swiss
|
||||
Ephemeris sunrise; not ``jaimini.calc_sunrise_utc_hours``, BUG-1462)
|
||||
- Yogi / Avayogi: ``annual_native.yogi_sphuta.build_yogi_sphuta_local``
|
||||
- arudha padas A1–A11 and UL: ``jaimini.calc_arudha_padas``
|
||||
- friendship (natural, temporary, compound): ``jyotish_engine._build_planetary_friendship_snapshot``
|
||||
|
||||
Each block fails on its own (``status: blocked`` + reason); one failure never
|
||||
fails the response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
SIGNS = (
|
||||
"Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo",
|
||||
"Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces",
|
||||
)
|
||||
|
||||
|
||||
class ChartPointsInputError(ValueError):
|
||||
"""The request is missing the birth moment or the natal chart."""
|
||||
|
||||
|
||||
def _number(body: dict[str, Any], key: str) -> float:
|
||||
value = body.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ChartPointsInputError(f"{key} must be a number")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _guard(build: Callable[[], Any]) -> Any:
|
||||
try:
|
||||
return build()
|
||||
except Exception as exc: # each block is independent
|
||||
return {"status": "blocked", "reason": f"{exc.__class__.__name__}: {exc}"}
|
||||
|
||||
|
||||
def _longitude(planet: Any) -> float | None:
|
||||
if not isinstance(planet, dict):
|
||||
return None
|
||||
for key in ("lon", "degree_raw", "longitude"):
|
||||
value = planet.get(key)
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(value) % 360.0
|
||||
return None
|
||||
|
||||
|
||||
def _nakshatra(longitude: float) -> dict[str, Any]:
|
||||
from jyotish_engine import _nakshatra_fields
|
||||
|
||||
return _nakshatra_fields(longitude)
|
||||
|
||||
|
||||
def _point(key: str, longitude: float, asc_sign: int, **extra: Any) -> dict[str, Any]:
|
||||
longitude = longitude % 360.0
|
||||
sign = int(longitude // 30) % 12
|
||||
return {
|
||||
**_nakshatra(longitude),
|
||||
"key": key,
|
||||
"longitude": round(longitude, 4),
|
||||
"sign": SIGNS[sign],
|
||||
"degree_in_sign": round(longitude % 30, 4),
|
||||
"house": (sign - asc_sign) % 12 + 1,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
UPAGRAHA_ORDER = (
|
||||
"Dhuma", "Vyatipata", "Parivesha", "Indrachapa", "Upaketu",
|
||||
"Kaala", "Mrityu", "Artha_Praharaka", "Yama_Ghantaka", "Gulika", "Maandi",
|
||||
)
|
||||
SPECIAL_ORDER = (
|
||||
"Bhava_Lagna", "Hora_Lagna", "Ghati_Lagna", "ViGhati_Lagna",
|
||||
"Pranapada_Lagna", "Sree_Lagna", "Indu_Lagna", "Varnada_Lagna",
|
||||
)
|
||||
ARUDHA_ORDER = ("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL")
|
||||
|
||||
|
||||
def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(body, dict):
|
||||
raise ChartPointsInputError("request body must be an object")
|
||||
planets = body.get("planets")
|
||||
ascendant = body.get("ascendant")
|
||||
if not isinstance(planets, dict) or not isinstance(ascendant, dict):
|
||||
raise ChartPointsInputError("planets and ascendant from /api/chart are required")
|
||||
asc_lon = _longitude(ascendant)
|
||||
sun_lon = _longitude(planets.get("Sun"))
|
||||
if asc_lon is None or sun_lon is None:
|
||||
raise ChartPointsInputError("ascendant and Sun longitudes are required")
|
||||
year, month, day = int(_number(body, "year")), int(_number(body, "month")), int(_number(body, "day"))
|
||||
hour, minute = int(_number(body, "hour")), int(_number(body, "minute"))
|
||||
second = int(body.get("second") or 0)
|
||||
lat, lon, tz = _number(body, "lat"), _number(body, "lon"), _number(body, "tz")
|
||||
ayanamsa = str(body.get("ayanamsa") or "raman")
|
||||
birth = datetime(year, month, day, hour, minute, second)
|
||||
asc_sign = int(asc_lon // 30) % 12
|
||||
|
||||
def upagrahas() -> dict[str, Any]:
|
||||
from gulika import calculate_upagrahas
|
||||
|
||||
packet = calculate_upagrahas(birth, lat=lat, lon=lon, tz=tz, sun_longitude=sun_lon, ayanamsa=ayanamsa)
|
||||
raw = packet.get("raw") or {}
|
||||
rows = [
|
||||
_point(name, float(raw[name]["longitude"]), asc_sign,
|
||||
kind="solar" if raw[name].get("source") == "solar_longitude_formula" else "time_segment")
|
||||
for name in UPAGRAHA_ORDER if isinstance(raw.get(name), dict) and "longitude" in raw[name]
|
||||
]
|
||||
return {"status": "computed", "rows": rows, "boundary": packet.get("boundary")}
|
||||
|
||||
def special() -> dict[str, Any]:
|
||||
from native_special_lagnas import native_special_lagna_rows
|
||||
|
||||
rows = native_special_lagna_rows(birth, lat, lon, tz, asc_lon, ayanamsa)
|
||||
out = [
|
||||
_point(key, float(rows[key]["longitude"]), asc_sign)
|
||||
for key in SPECIAL_ORDER
|
||||
if isinstance(rows.get(key), dict) and rows[key].get("status") == "computed"
|
||||
]
|
||||
if not out:
|
||||
reasons = {row.get("reason") for row in rows.values() if isinstance(row, dict)}
|
||||
return {"status": "blocked", "reason": ";".join(sorted(str(r) for r in reasons if r))}
|
||||
return {"status": "computed", "rows": out}
|
||||
|
||||
def yogi() -> dict[str, Any]:
|
||||
from annual_native.yogi_sphuta import build_yogi_sphuta_local
|
||||
|
||||
packet = build_yogi_sphuta_local({"planets": planets, "ascendant": ascendant})
|
||||
rows = []
|
||||
for key, name in (("yogi", "Yogi_Point"), ("ava_yogi", "Avayogi_Point")):
|
||||
part = packet.get(key)
|
||||
if isinstance(part, dict) and isinstance(part.get("longitude"), (int, float)):
|
||||
rows.append(_point(name, float(part["longitude"]), asc_sign))
|
||||
if not rows:
|
||||
return {"status": "blocked", "reason": str(packet.get("reason") or "yogi_points_missing")}
|
||||
return {"status": "computed", "rows": rows, "formula": packet.get("formula")}
|
||||
|
||||
def arudha() -> dict[str, Any]:
|
||||
from jaimini import calc_arudha_padas
|
||||
|
||||
longitudes = {name: value for name, value in ((n, _longitude(p)) for n, p in planets.items()) if value is not None}
|
||||
padas = calc_arudha_padas(asc_sign, longitudes).get("padas") or {}
|
||||
rows = []
|
||||
for key in ARUDHA_ORDER:
|
||||
pada = padas.get(key)
|
||||
if not isinstance(pada, dict) or pada.get("sign") not in SIGNS:
|
||||
continue
|
||||
sign = SIGNS.index(pada["sign"])
|
||||
rows.append({
|
||||
"key": key,
|
||||
"sign": pada["sign"],
|
||||
"house": (sign - asc_sign) % 12 + 1,
|
||||
"source_house": int(pada.get("house_num") or ARUDHA_ORDER.index(key) + 1),
|
||||
"source_lord": pada.get("source_house_lord"),
|
||||
"exception": bool(pada.get("exception_triggered")),
|
||||
})
|
||||
return {"status": "computed", "rows": rows}
|
||||
|
||||
def friendship() -> dict[str, Any]:
|
||||
from jyotish_engine import _build_planetary_friendship_snapshot
|
||||
|
||||
return _build_planetary_friendship_snapshot(planets)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"endpoint": "chart_points",
|
||||
"ascendant_sign": SIGNS[asc_sign],
|
||||
"upagrahas": _guard(upagrahas),
|
||||
"special_lagnas": _guard(special),
|
||||
"yogi_points": _guard(yogi),
|
||||
"arudha_padas": _guard(arudha),
|
||||
"friendship": _guard(friendship),
|
||||
}
|
||||
@@ -10520,6 +10520,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, JyotishComputeService):
|
||||
except match_api.MarriageMatchInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
self._json(result)
|
||||
elif path == '/api/chart_points':
|
||||
points_api = _load_local_module('chart_view_points.api')
|
||||
try:
|
||||
result = points_api.compute_chart_points(body)
|
||||
except points_api.ChartPointsInputError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
self._json(result)
|
||||
elif path == '/api/synastry_full_reader_gate':
|
||||
match_api = _load_local_module('marriage_match_service.api')
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""`/api/chart_points` (issue #114): the chart page's data tables come from existing producers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPTS = ROOT / "scripts"
|
||||
if str(SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
from chart_view_points.api import ChartPointsInputError, SIGNS, compute_chart_points # noqa: E402
|
||||
|
||||
# Real engine /api/chart output (1990-06-15 12:00 Beijing, raman), shared with the frontend tests.
|
||||
GOLDEN = json.loads((ROOT / "frontend" / "tests" / "fixtures" / "chart-insight-golden.json").read_text(encoding="utf-8"))
|
||||
BODY = {
|
||||
"year": 1990, "month": 6, "day": 15, "hour": 12, "minute": 0, "second": 0,
|
||||
"lat": 39.9042, "lon": 116.4074, "tz": 8, "ayanamsa": "raman",
|
||||
"planets": GOLDEN["chart"]["planets"], "ascendant": GOLDEN["chart"]["ascendant"],
|
||||
}
|
||||
|
||||
|
||||
def _sign_index(longitude: float) -> int:
|
||||
return int((longitude % 360) // 30)
|
||||
|
||||
|
||||
def test_every_block_is_computed_from_the_engine_producers():
|
||||
result = compute_chart_points(BODY)
|
||||
assert result["success"] is True
|
||||
assert result["upagrahas"]["status"] == "computed"
|
||||
assert [row["key"] for row in result["upagrahas"]["rows"]][:5] == ["Dhuma", "Vyatipata", "Parivesha", "Indrachapa", "Upaketu"]
|
||||
assert len(result["upagrahas"]["rows"]) == 11
|
||||
assert result["special_lagnas"]["status"] == "computed"
|
||||
assert len(result["special_lagnas"]["rows"]) == 8
|
||||
assert [row["key"] for row in result["yogi_points"]["rows"]] == ["Yogi_Point", "Avayogi_Point"]
|
||||
assert [row["key"] for row in result["arudha_padas"]["rows"]] == ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL"]
|
||||
assert len(result["friendship"]["rows"]) == 9
|
||||
|
||||
|
||||
def test_points_carry_sign_and_house_counted_from_the_ascendant():
|
||||
result = compute_chart_points(BODY)
|
||||
asc = _sign_index(float(BODY["ascendant"]["lon"]))
|
||||
for block in ("upagrahas", "special_lagnas", "yogi_points"):
|
||||
for row in result[block]["rows"]:
|
||||
sign = _sign_index(row["longitude"])
|
||||
assert row["sign"] == SIGNS[sign]
|
||||
assert row["house"] == (sign - asc) % 12 + 1
|
||||
assert 0 <= row["degree_in_sign"] < 30
|
||||
|
||||
|
||||
def test_solar_upagrahas_follow_the_sun_formula():
|
||||
sun = float(BODY["planets"]["Sun"]["lon"])
|
||||
rows = {row["key"]: row["longitude"] for row in compute_chart_points(BODY)["upagrahas"]["rows"]}
|
||||
dhuma = (sun + 133 + 20 / 60) % 360
|
||||
assert rows["Dhuma"] == pytest.approx(dhuma, abs=1e-3)
|
||||
assert rows["Vyatipata"] == pytest.approx((360 - dhuma) % 360, abs=1e-3)
|
||||
assert rows["Parivesha"] == pytest.approx((rows["Vyatipata"] + 180) % 360, abs=1e-3)
|
||||
|
||||
|
||||
def test_avayogi_is_yogi_plus_186_40():
|
||||
rows = {row["key"]: row["longitude"] for row in compute_chart_points(BODY)["yogi_points"]["rows"]}
|
||||
assert (rows["Avayogi_Point"] - rows["Yogi_Point"]) % 360 == pytest.approx(186 + 40 / 60, abs=1e-3)
|
||||
|
||||
|
||||
def test_friendship_compound_is_natural_plus_temporary():
|
||||
rows = compute_chart_points(BODY)["friendship"]["rows"]
|
||||
sun = next(row for row in rows if row["planet"] == "Sun")
|
||||
for target in sun["compound"]["great_friends"]:
|
||||
assert target in sun["natural"]["friends"] and target in sun["temporary"]["friends"]
|
||||
for target in sun["compound"]["great_enemies"]:
|
||||
assert target in sun["natural"]["enemies"] and target in sun["temporary"]["enemies"]
|
||||
|
||||
|
||||
def test_one_failing_block_does_not_fail_the_response(monkeypatch):
|
||||
import gulika
|
||||
|
||||
def broken(*_args, **_kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(gulika, "calculate_upagrahas", broken)
|
||||
result = compute_chart_points(BODY)
|
||||
assert result["upagrahas"]["status"] == "blocked"
|
||||
assert result["arudha_padas"]["status"] == "computed"
|
||||
|
||||
|
||||
def test_missing_birth_or_chart_is_a_bad_request():
|
||||
with pytest.raises(ChartPointsInputError):
|
||||
compute_chart_points({**BODY, "planets": None})
|
||||
with pytest.raises(ChartPointsInputError):
|
||||
compute_chart_points({key: value for key, value in BODY.items() if key != "lat"})
|
||||
|
||||
|
||||
def test_route_is_registered_thinly():
|
||||
source = (SCRIPTS / "jyotish_api_server.py").read_text(encoding="utf-8")
|
||||
assert "elif path == '/api/chart_points':" in source
|
||||
assert "_load_local_module('chart_view_points.api')" in source
|
||||
Reference in New Issue
Block a user