diff --git a/CHANGELOG.md b/CHANGELOG.md index f924bada..514e817d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 印度占星 Skill 更新日志 +## 2026-10-27 — 星盘页加数据表和标点图层(issue #114 第二轮) + +- 排盘底册多了「数据表」:行星、虚星、特殊点、映射、星力明细、敌友六张。手机上第一列固定,其余左右滑。 +- 盘上可以一次标出全部虚星、特殊点或映射。点任何一个点,检视里写它是什么、在哪一宫、怎么来的;映射会用虚线连回它来自的那一宫。 +- 星力明细把六力拆成总表、位置力、时间力和其余四力,都是数字。敌友分天生、临时、综合三种看法。 +- 八宫点表多了一行「升」(上升自己的点数,不算进合计)。 +- 特殊点用的日出是 Swiss Ephemeris 算的,不受之前发现的日出错误影响。 +- Skill 版本不变。 + ## 2026-10-27 — 星盘页一页铺开,点星点宫都会联动(issue #114) - 星盘页不再分四个页签。上面是盘和「盘面检视」,旁边是九曜、宫位主题、大运和命盘速览,下面「深读」四页:时运流年、能量图谱、命性格局、排盘底册。西洋盘在页头切换。 diff --git a/docs/tasks/PROGRESS-chart-ui-114-20261027.md b/docs/tasks/PROGRESS-chart-ui-114-20261027.md index 5e34d533..d3a0269c 100644 --- a/docs/tasks/PROGRESS-chart-ui-114-20261027.md +++ b/docs/tasks/PROGRESS-chart-ui-114-20261027.md @@ -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`,由同一模块对金样星盘实算)。 diff --git a/docs/testing/chart-board-114-20261027.md b/docs/testing/chart-board-114-20261027.md index 13c25da7..356fbb67 100644 --- a/docs/testing/chart-board-114-20261027.md +++ b/docs/testing/chart-board-114-20261027.md @@ -21,6 +21,14 @@ 12. 命性格局:只列成立的格局;点一条,盘上只留这几颗星的颜色;「未满足 N 条」默认折起。 13. 排盘底册:出生资料表、出生历法(提毗、月宿、星期)、行星名册(有 D9 列)、宫主流向(每宫都有,没有「未提供」)。 +## 第二轮:数据表与标点 + +17. 盘卡第二组切到「虚星」:盘上出现 11 枚小胶囊(烟、毁、晕、虹、彗、时、死、利、阎、古、曼);切「特殊点」「映射」同理,映射是 AL、A2…UL。切到 D9 时这一组消失。 +18. 点盘上的「UL」:它所在的宫高亮,有一条虚线连回第 12 宫;检视写「映射」、传统含义、来历。方向键切到 AL。 +19. 深读 → 排盘底册 → 数据表:六个页签都有内容;手机上左右滑时第一列不动。点「虚星」表里任一行,盘上那一枚变实心。 +20. 星力明细切「位置力」「时间力」「其余四力」,数字会变;敌友切「天生」「临时」「综合」。 +21. 能量图谱的八宫点表,原始口径下第一行是「升」,合计不含它。 + ## 对话页 14. 电脑:新开对话,还没发任何话,右边已经有盘。拖右栏左边缘变宽变窄,刷新后宽度还在;拖到最窄以下会收起,顶栏按钮能再打开。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 399edb4f..349f269b 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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`,只随星盘页和右栏的懒加载块到达,不进首页首屏。 diff --git a/frontend/src/components/chart-board/chart-board-card.tsx b/frontend/src/components/chart-board/chart-board-card.tsx index d0f8eacf..332c110e 100644 --- a/frontend/src/components/chart-board/chart-board-card.tsx +++ b/frontend/src/components/chart-board/chart-board-card.tsx @@ -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 = [ ["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({ ) : } + {natal && onPointLayer ? ( + value && onPointLayer(value as PointGroup | "none")}> + + 不标点 + 虚星 + 特殊点 + 映射 + + + ) : null} value && onStyle(value as ChartBoardStyle)}> 北印度 @@ -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} /> ) : !pending && failure ? ( @@ -204,6 +238,9 @@ export function ChartBoardCard({ {natal && activeLayer === "bhava" && mean !== null ? (

宫力:本盘十二宫均值 {Math.round(mean)},高出一成记「{BAND_ZH.strong}」,低一成记「{BAND_ZH.weak}」。

) : null} + {natal && pointLayer !== "none" && pointsFailure && !pointsPending ? ( +

{chartLayerFailureCopy(pointsFailure)}

+ ) : null} {natal && activeLayer === "transit" && transit ? (

蓝字是 {transit.date} 的行运位置。

) : null} diff --git a/frontend/src/components/chart-board/chart-board-svg.tsx b/frontend/src/components/chart-board/chart-board-svg.tsx index 219ed3de..46f9e83a 100644 --- a/frontend/src/components/chart-board/chart-board-svg.tsx +++ b/frontend/src/components/chart-board/chart-board-svg.tsx @@ -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> = { 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; /** 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(); 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(); + for (const mark of pointMarks) marksBySign.set(mark.sign, [...(marksBySign.get(mark.sign) ?? []), mark]); + const selectedPoint = selection?.kind === "point" ? selection.key : null; return ( ) : 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 ( + + {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 ( + { + event.stopPropagation(); + pick(); + }} + onKeyDown={(event) => activateOnKey(event, pick)} + > + + {mark.short} + + ); + })} + + ); + })} {style === "south" && center ? ( {av.lagnaBav![sign] ?? 0})} + {av.lagnaBav.reduce((sum, value) => sum + value, 0)} + + ) : 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 }: {
- {SAV_REFERENCE} 是传统参考线,不是本盘均值。 + {SAV_REFERENCE} 是传统参考线,不是本盘均值。「升」是上升自己的一行,不计入合计。
@@ -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 = [ + ["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> = { 2: "大友", 1: "友", 0: "中", [-1]: "敌", [-2]: "大敌" }; + +function PointTable({ view, board, group }: { view: ChartViewOk; board: ChartBoardState; group: PointGroup }) { + const rows = pointsOf(view, group); + if (!rows) return

这张表暂时没算出来。

; + const picked = board.selection?.kind === "point" ? board.selection.key : null; + const arudha = group === "arudha"; + return ( +
+ + + + + + + {arudha ? : <>} + + + + + {rows.map((point) => { + const on = picked === point.key; + const pick = () => board.pickPoint(on ? null : point); + return ( + + + + + {arudha + ? + : <>} + + + ); + })} + +
名称{arudha ? "星座" : "黄道经度"}宫位来自月宿宿主足传统含义
+ + {point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""}{point.house}宫第 {point.sourceHouse ?? "—"} 宫 · 主 {point.sourceLord ?? "—"}{point.nakshatra ?? "—"}{point.nakshatraLord ?? "—"}{point.pada ?? "—"}{point.note}
+
+ ); +} + +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

这张盘没有返回六力。

; + 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 ( + <> +
+ value && setPart(value as typeof part)}> + + 总表 + 位置力 + 时间力 + 其余四力 + + +
+
+ + {head.map((label) => )} + + {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 = ( + <> + + + + + + + ); + } else if (part === "other") { + cells = row.parts + ? ["dig", "chesta", "naisargika", "drik"].map((id) => ) + : ; + } else { + const list = part === "sthana" ? STHANA_PARTS : KALA_PARTS; + const values = part === "sthana" ? row.sthanaParts : row.kalaParts; + cells = values + ? <> + {list.map(([id]) => )} + + + : ; + } + return ( + key && board.select(on ? null : { kind: "body", key })}> + + {cells} + + ); + })} + +
行星{label}
{total.toFixed(1)}{required.toFixed(0)}{row.required.toFixed(2)}{row.percent.toFixed(1)}%{STRENGTH_LEVEL_ZH[row.level]}= required ? "chart-graha-good" : "chart-graha-bad"}`}>{total >= required ? "+" : ""}{(total - required).toFixed(1)}{row.parts![id as "dig"].toFixed(1)}—{(values as Record)[id]!.toFixed(1)}{(part === "sthana" ? row.parts?.sthana : row.parts?.kala)?.toFixed(1) ?? "—"}—
{row.label}
+
+

单位维鲁帕,60 维鲁帕 = 1 鲁帕。图形版在「能量图谱」。

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

这张表暂时没算出来。

; + const names = friendship.rows.map((row) => row.name); + const label = (value: number) => mode === "compound" ? RELATION_ZH[value] ?? "—" : value > 0 ? "友" : value < 0 ? "敌" : "中"; + return ( + <> +
+ value && setMode(value as typeof mode)}> + + 天生 + 临时 + 综合 + + +
+
+ + + {names.map((name) => )} + + + {friendship.rows.map((row) => ( + + + {names.map((target) => { + const cell = row.targets.find((item) => item.name === target); + if (!cell) return ; + const value = cell[mode]; + return ; + })} + + ))} + +
星体{PLANET_GLYPH_ZH[planetGlyphKey(name) ?? "sun"]}
{row.label}— 0 ? "chart-graha-good" : value < 0 ? "chart-graha-bad" : undefined}>{label(value)}
+
+

行是这颗星怎么看列上的星。临时关系看两星在盘上的相对位置(第 2、3、4、10、11、12 位为友);综合是两者合起来分五档。

+ + ); +} + +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("planets"); + // The data tables read the points layer. + useEffect(() => { + onNeedLayer?.("points"); + }, [onNeedLayer]); + const pointsPending = view.points?.status !== "ok" && board.pointsPending; return (
@@ -548,67 +745,77 @@ function BookTab({ view }: { view: ChartViewOk }) {
) : null} -
-
-
- - - - - - - - - - - - {view.vedic.planets.map((row) => { - const key = planetGlyphKey(row.name); - const dignity = dignityStep(row.status); - return ( - - - - - - - - ); - })} - -
星体位置月宿状态D9
- {key ? {PLANET_GLYPH_ZH[key]} : null} - {row.label} - {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫{row.nakshatra}{dignity?.label ?? "—"}{row.retrograde ? " · 逆" : ""}{row.d9SignLabel ?? "—"}
+
+ value && setTable(value as BookTable)}> + + {BOOK_TABLES.map(([id, label]) => {label})} + + + {table === "planets" ? ( +
+
+ + + + + + + + + + + + {view.vedic.planets.map((row) => { + const key = planetGlyphKey(row.name); + const dignity = dignityStep(row.status); + return ( + key && board.select({ kind: "body", key })}> + + + + + + + ); + })} + +
星体位置月宿状态D9
+ {key ? {PLANET_GLYPH_ZH[key]} : null} + {row.label} + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫{row.nakshatra}{dignity?.label ?? "—"}{row.retrograde ? " · 逆" : ""}{row.d9SignLabel ?? "—"}
+
+
+ + + + + + + + + + {natal.houses.map((house) => { + const lord = houseLord(natal, house.house); + const at = lord ? bodyHouse(natal, lord) : null; + return ( + board.select({ kind: "house", house: house.house })}> + + + + + ); + })} + +
宫主流向宫主落宫
{house.house}宫 · {signLabelAt(house.sign)}{lord ? PLANET_NAME_ZH[lord] : "—"}{at ? `${at}宫 · ${signLabelAt(natal.houses[at - 1]!.sign)}` : "—"}
+
-
-
-
- - - - - - - - - - {natal.houses.map((house) => { - const lord = houseLord(natal, house.house); - const at = lord ? bodyHouse(natal, lord) : null; - return ( - - - - - - ); - })} - -
宫位宫主落宫
{house.house}宫 · {signLabelAt(house.sign)}{lord ? PLANET_NAME_ZH[lord] : "—"}{at ? `${at}宫 · ${signLabelAt(natal.houses[at - 1]!.sign)}` : "—"}
-
-
-
+ ) : null} + {table === "upagraha" || table === "special" || table === "arudha" ? ( + pointsPending ?

: + ) : null} + {table === "strength" ? : null} + {table === "relation" ? (pointsPending ?

: ) : null} +
); } @@ -685,7 +892,7 @@ export function ChartDeepReading({ ) : null} {tab === "yoga" ? : null} - {tab === "book" ? : null} + {tab === "book" ? : null} ); diff --git a/frontend/src/components/chart-board/chart-inspector.tsx b/frontend/src/components/chart-board/chart-inspector.tsx index e9e090cb..7a7ed30a 100644 --- a/frontend/src/components/chart-board/chart-inspector.tsx +++ b/frontend/src/components/chart-board/chart-inspector.tsx @@ -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 ( + <> +
+ {point.nakshatra ? ( +
+ ) : null} +
+ {point.sourceHouse ? ( +
+

虚线连回第 {point.sourceHouse} 宫。

+
+ ) : null} +
0 ? companions.join("、") : "无"} /> + + ); +} + function Section({ label, value, children }: { label: string; value?: string; children?: ReactNode }) { return (
@@ -230,6 +260,43 @@ export function ChartInspector({ ); } + 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 ( + + + 盘面检视 + + + + + + + + {point ? ( + <> +
+ {point.short} +
+

{point.label} {point.english}

+

{point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""} · 第{house ?? point.house}宫

+
{POINT_GROUP_ZH[point.group]}
+
+
+ +
+ +
+ + ) :

这个点暂时没算出来

} +
+
+ ); + } const isBody = selection.kind === "body"; const house = isBody ? bodyHouse(model, selection.key) : selection.house; const sign = house ? model.houses[house - 1]?.sign ?? -1 : -1; diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 10ce6198..d6d78acc 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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} /> diff --git a/frontend/src/components/chat-natal-chart.tsx b/frontend/src/components/chat-natal-chart.tsx index 983ed394..65966deb 100644 --- a/frontend/src/components/chat-natal-chart.tsx +++ b/frontend/src/components/chat-natal-chart.tsx @@ -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 diff --git a/frontend/src/hooks/use-chart-board.ts b/frontend/src/hooks/use-chart-board.ts index d50c9df5..10a5e756 100644 --- a/frontend/src/hooks/use-chart-board.ts +++ b/frontend/src/hooks/use-chart-board.ts @@ -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(null); const [layer, setLayerState] = useState("none"); const [yogaBodies, setYogaBodies] = useState(null); + /** 标点 layer: every point of one group drawn on the board at once (issue #114). */ + const [pointLayer, setPointLayerState] = useState("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, diff --git a/frontend/src/hooks/use-chart-page.ts b/frontend/src/hooks/use-chart-page.ts index 697473d1..ea78f9b4 100644 --- a/frontend/src/hooks/use-chart-page.ts +++ b/frontend/src/hooks/use-chart-page.ts @@ -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(); diff --git a/frontend/src/lib/chart-board-model.ts b/frontend/src/lib/chart-board-model.ts index 4b4fcef6..c41bfb5c 100644 --- a/frontend/src/lib/chart-board-model.ts +++ b/frontend/src/lib/chart-board-model.ts @@ -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); diff --git a/frontend/src/lib/chart-board-view.ts b/frontend/src/lib/chart-board-view.ts index 5d01327c..12bddb2c 100644 --- a/frontend/src/lib/chart-board-view.ts +++ b/frontend/src/lib/chart-board-view.ts @@ -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> = { + upagraha: "虚星", + special: "特殊点", + arudha: "映射", +}; diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 592c3c7f..910affd0 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -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; export type ChartViewBirthCalendar = z.infer; export type ChartViewAshtakavargaOk = z.infer; +export type ChartViewPointsOk = z.infer; +export type ChartViewPoint = z.infer; export const CHART_VIEW_TABS = [ { id: "vedic", label: "星盘" }, diff --git a/frontend/src/lib/chart-view-engine.ts b/frontend/src/lib/chart-view-engine.ts index cce9daa5..ba82bedc 100644 --- a/frontend/src/lib/chart-view-engine.ts +++ b/frontend/src/lib/chart-view-engine.ts @@ -2,7 +2,7 @@ export const CHART_VIEW_ENGINE_TIMEOUT_MS = 10_000; export const CHART_VIEW_ENGINE_CACHE_TTL_MS = 5 * 60 * 1_000; const ENGINE_CACHE_MAX_ENTRIES = 200; -export const CHART_VIEW_LAYERS = ["varga", "chara", "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]; /** diff --git a/frontend/src/lib/chart-view-insight.ts b/frontend/src/lib/chart-view-insight.ts index fb4761bf..fc2e6fdb 100644 --- a/frontend/src/lib/chart-view-insight.ts +++ b/frontend/src/lib/chart-view-insight.ts @@ -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): ChartViewShadbalaRow["part return parts as NonNullable; } +function sthanaParts(row: Record): 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; +} + +function kalaParts(row: Record): 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; +} + /** * 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): 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, 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> = { + 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> = { + 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, 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 | 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; +} diff --git a/frontend/src/lib/chart-view-load.ts b/frontend/src/lib/chart-view-load.ts index f4394e2b..89f45c4a 100644 --- a/frontend/src/lib/chart-view-load.ts +++ b/frontend/src/lib/chart-view-load.ts @@ -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, diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 1726fee2..1bebc94d 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -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 | null; ashtakavarga?: { ashtakavarga: Record | null; bhavaBala: Record | 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); } diff --git a/frontend/tests/chart-board-20261027.test.tsx b/frontend/tests/chart-board-20261027.test.tsx index 45c8f260..d1432453 100644 --- a/frontend/tests/chart-board-20261027.test.tsx +++ b/frontend/tests/chart-board-20261027.test.tsx @@ -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; ashtakavarga: Record; bhava_bala: Record; + chart_points: Record; second_case: { yogas: unknown[]; vimshottari_current: { antardasha: { lord: string; start: string; end: string } }; @@ -68,6 +70,7 @@ async function okView(): Promise { 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+宫,说明什么?$/); +}); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 8e339874..f51588f6 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -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, /宫主流向/); } diff --git a/frontend/tests/fixtures/chart-insight-golden.json b/frontend/tests/fixtures/chart-insight-golden.json index e5e6e9dd..d81aa5fd 100644 --- a/frontend/tests/fixtures/chart-insight-golden.json +++ b/frontend/tests/fixtures/chart-insight-golden.json @@ -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." + } } } \ No newline at end of file diff --git a/scripts/chart_view_points/__init__.py b/scripts/chart_view_points/__init__.py new file mode 100644 index 00000000..523f1ef2 --- /dev/null +++ b/scripts/chart_view_points/__init__.py @@ -0,0 +1 @@ +"""Chart-page data tables (issue #114): upagrahas, special lagnas, arudha padas, friendship.""" diff --git a/scripts/chart_view_points/api.py b/scripts/chart_view_points/api.py new file mode 100644 index 00000000..f24db35a --- /dev/null +++ b/scripts/chart_view_points/api.py @@ -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), + } diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index aa8da377..863f9367 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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: diff --git a/tests/test_chart_view_points_api.py b/tests/test_chart_view_points_api.py new file mode 100644 index 00000000..a0fc9a7f --- /dev/null +++ b/tests/test_chart_view_points_api.py @@ -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