Merge branch 'codex/163-engine-columns' into codex/integrate-163
# Conflicts: # CHANGELOG.md # frontend/src/components/chart-board/chart-data-card.tsx
This commit is contained in:
@@ -11,6 +11,17 @@
|
||||
- 名称:「八分点」改叫「八分法」(提示里写全称「八分法(Ashtakavarga)」),分数仍写「N 点」,表里分出单星表 BAV 和总表 SAV;「映射」改叫「Arudha 映射」。
|
||||
- 占星师 2026-11-02 在 issue #163 的答复第 4–8 条。Skill 版本不变。
|
||||
|
||||
## 2026-11-02 — 盘面数据:功能吉凶列、Chara Karaka 七 / 八星切换、特殊点三个新组
|
||||
|
||||
- 「行星」表新增四列:自然属性、管辖宫位、功能角色、补充身份。功能角色来自引擎的 `bphs_ch34_general_with_sign_exceptions_v2`(BPHS 第 34 章译本 + 各上升明文例外),写「偏吉/支持」「偏凶/挑战」「混合/待综合」;罗睺、计都写「待综合」,不写「中性」。补充身份单列 Yogakaraka(严格定义:分别主管 4/7/10 之一和 5/9 之一)、Badhaka(动/固/双上升取 11/9/7 宫主)和「Kendradhipati 待综合」,都不改变功能角色。自然属性和功能角色方向相反时,功能角色带虚下划线,悬停说明。出处写在列头的悬停提示里。
|
||||
- 「指标」列改名「Chara Karaka」,单元格写「AK · 自我」「AmK · 职事」等,梵文全名和含义在悬停提示里。表头多了「七星制 / 八星制」切换,默认七星;八星制含罗睺(按 30° 减星座内度数排)、不含计都。按未取整的星座内度数排序;只有原始精度下仍同度的几颗星写「并列/角色待定」,其余角色照常显示,不再整列留空(BUG-1512)。Goel 的星座力用同一套排序。
|
||||
- 「特殊点」分四组:特殊上升 / 敏感点 / Saham / Goel 特殊点,盘上的「特殊点」图层跟着当前组走。
|
||||
- 敏感点:Bhrigu 点(罗睺到月亮顺行弧中点,跟随本盘的交点口径)、第 64 Navamsa(从月亮)、第 22 Drekkana(从上升),每项写明参照;后两项只有星座和星座主,不补度数。
|
||||
- Saham:先列 Punya、Vidya、Yashas、Mitra、Vivaha、Artha、Labha、Karyasiddhi、Paradesa 九项,其余已算的放在「全部」里(共 56 项,其中 20 项旧式候选标明未核对来源)。公式按 PVR《Vedic Astrology: An Integrated Approach》§28.8.1 表 74,逐项处理昼夜、不反转的例外和 +30°;昼夜按出生时地。公式列写出当次用的公式。
|
||||
- Goel 特殊点:Brahma / Maheshwara / Rudra 按「书面规则含交点」取星,旁边一列写「示例排除交点」的结果,两种口径取到不同的星时标出来。Karaka 仍按七星制。
|
||||
- 接口 `/api/chart_points` 新增 `karaka_scheme`(`7` 默认、`8`、`both`)和 `functional_roles`、`sensitive_points`、`sahams`、`goel_points` 四块;每块单独失败,不影响其它。
|
||||
- 产品 2026-11-02 按占星师答复定(issue #163 第 1—3 项)。Skill 版本不变。
|
||||
|
||||
## 2026-11-02 — 对话输入框左边加「常用问题」
|
||||
|
||||
- 首页对话输入框左下角多了一个圆形按钮「常用问题」。点开先选分类(认识自己、感情与婚姻、事业与工作、钱与财富、家庭、父母、孩子、学习与考试、压力与生活节奏、居住与迁移、未来十二个月、时机),再选一个问题,问题全文会填进输入框,不会直接发出去。
|
||||
|
||||
@@ -20742,3 +20742,18 @@
|
||||
- 相关记录:BUG-1470(顶栏高度与两行问题);同一变更是 2026-11-02 顶栏控件统一 44px 与选择星盘改版。
|
||||
- 复发自:无
|
||||
- 修复版本:提交 1cd73bc8(分支 `codex/chart-polish-1102`),2026-11-02 推上 staging。
|
||||
|
||||
## BUG-1512 | 盘面数据 Chara Karaka 列:两颗星四位小数相同就整列留空,同度时也没有明确的并列信息
|
||||
|
||||
- 状态:resolved(代码 + 回归测试;待部署)
|
||||
- 首次发现 / 最近更新:2026-11-02 / 2026-11-02(issue #163 占星师答复 2)
|
||||
- 影响面:`scripts/chart_view_points/api.py` 的 `chara_karakas` 块;星盘页「盘面数据 · 行星」的 Karaka 列;`scripts/goel_jaimini_strength.py` 的同度判断。
|
||||
- 用户现象:七颗星里有两颗的星座内度数取到四位小数相同,Karaka 列七格全部是「—」,没有角色、也看不出是哪两颗星。
|
||||
- 触发条件:两颗星星座内度数四舍五入到 0.0001° 后相等(不必真的同度)。
|
||||
- 根因:接口先把度数取四位小数再比较,相等就整块返回 `blocked: tied_degrees`;前端拿不到任何一行就整列留空。`jaimini.calc_chara_karaka_7 / 8` 自身遇到真正同度时按输入顺序排,不报并列;Goel 另写了一套 1e-10 的两两比较,两条路径口径不同。
|
||||
- 修复:新增 `scripts/chart_view_points/karaka_rank.py::rank_chara_karakas`(七星 / 八星,按未取整度数排,1e-10 内相等才算并列),并列的几颗星跨的角色标 `tied`、列出候选,其余角色保留;接口和 Goel 都用它,Goel 遇到并列仍整体 blocked 并带上 `karaka_ties`。前端并列格写「并列/角色待定」。`jaimini.py` 在生时校正冻结身份里,没有改,非并列输入下两者排序一致。
|
||||
- 验证:`tests/test_chara_karaka_rank.py`(与 `calc_chara_karaka_7 / 8` 20 组随机输入排序一致、三星并列、输入顺序无关、罗睺反向度数并列);`tests/test_chart_view_points_api.py::test_chara_karakas_mark_only_the_tied_roles`(原断言整块 blocked,按三栏改)与 `test_chara_karakas_sort_by_unrounded_degree`;`frontend/tests/chart-data-engine-columns-20261102.test.tsx`「a karaka tie marks only the tied bodies」。
|
||||
- 防复发:上面的测试。规则:排序按原始精度;并列只标受影响的角色,不能清空整列,也不能按数组顺序硬分。
|
||||
- 相关记录:BUG-1194、BUG-1204(8 星制排序与次序)。
|
||||
- 复发自:无
|
||||
- 修复版本:分支 `codex/163-engine-columns`(基于 `origin/staging` 4dbaf518),待合并。
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
* of `/` and never reaches the Node test runner (which cannot load .css).
|
||||
*/
|
||||
import "./chart-board.css";
|
||||
import "./chart-data-engine.css";
|
||||
|
||||
@@ -18,8 +18,6 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
|
||||
import { Table } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import { ChartSymbolHelp } from "@/components/chart-page/chart-symbol-help";
|
||||
import { AscendantStatusCell } from "@/components/chart-page/chart-vedic-tab";
|
||||
import type { ChartBoardState } from "@/hooks/use-chart-board";
|
||||
import {
|
||||
ASHTAKAVARGA_TITLE,
|
||||
@@ -38,7 +36,6 @@ import {
|
||||
type DashaSystem,
|
||||
type RelationMode,
|
||||
} from "@/lib/chart-board-focus";
|
||||
import { dignityStep } from "@/lib/chart-board-model";
|
||||
import { natalBoard, pointsOf, progressOf, SAV_REFERENCE, shadbalaFor, SHADBALA_KEYS, type PointGroup } from "@/lib/chart-board-view";
|
||||
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
@@ -49,6 +46,8 @@ import { vimshottariSplit, type DashaSpan } from "@/lib/dasha-span";
|
||||
import { dragScroll } from "@/lib/drag-scroll";
|
||||
import { PLANET_GLYPH_ZH, PLANET_NAME_ZH, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
import { PlanetTable } from "./chart-data-planets";
|
||||
import { SpecialPointsPanel } from "./chart-data-special";
|
||||
import { ChartTypeTableBody } from "./chart-type-table";
|
||||
|
||||
const STHANA_PARTS = [["uccha", "高位力"], ["saptavarga", "七分盘力"], ["ojayugma", "奇偶力"], ["kendra", "角宫力"], ["drekkana", "三分力"]] as const;
|
||||
@@ -71,75 +70,6 @@ function Waiting({ pending, failure }: { pending: boolean; failure?: ChartViewFa
|
||||
: <p className="chart-page-note" aria-busy={pending}></p>;
|
||||
}
|
||||
|
||||
function PlanetTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const karakas = view.points?.status === "ok" ? view.points.karakas ?? null : null;
|
||||
const karakaOf = (name: string) => karakas?.find((row) => row.planet === name)?.key ?? "—";
|
||||
const current = board.selection?.kind === "body" ? board.selection.key : null;
|
||||
const asc = view.vedic.ascendant;
|
||||
const ascOn = board.selection?.kind === "house" && board.model !== null && board.selection.house === board.model.ascendantHouse;
|
||||
const pickAsc = () => board.model && board.select(ascOn ? null : { kind: "house", house: board.model.ascendantHouse });
|
||||
// ChartSymbolHelp shows the ascendant cell's help bubble on hover, focus or tap.
|
||||
return (
|
||||
<ChartSymbolHelp resetKey="data-planets">
|
||||
<Wide>
|
||||
<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">宿主</th>
|
||||
<th scope="col">旺陷</th>
|
||||
<th scope="col">指标</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{asc ? (
|
||||
<tr className={ascOn ? "is-selected" : undefined} aria-selected={ascOn} onClick={pickAsc}>
|
||||
<th scope="row">{asc.label}</th>
|
||||
<td className="chart-graha-num">{asc.signLabel} {formatDegreeMinutes(asc.degreeInSign)}</td>
|
||||
<td className="chart-graha-num">1</td>
|
||||
<td>{asc.nakshatra ? `${asc.nakshatra}${asc.pada ? ` · ${asc.pada}` : ""}` : "—"}</td>
|
||||
<td>{asc.nakshatraLord ?? "—"}</td>
|
||||
<AscendantStatusCell />
|
||||
<td>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{view.vedic.planets.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const on = key !== null && current === key;
|
||||
const dignity = dignityStep(row.status);
|
||||
const node = key === "rahu" || key === "ketu";
|
||||
return (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={on ? "is-selected" : undefined}
|
||||
aria-selected={on}
|
||||
onClick={() => key && board.select(on ? null : { kind: "body", key })}
|
||||
>
|
||||
<th scope="row">{row.label}</th>
|
||||
<td className="chart-graha-num">
|
||||
{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}
|
||||
{row.retrograde && !node ? <span className="chart-graha-retro"> 逆</span> : null}
|
||||
</td>
|
||||
<td className="chart-graha-num">{row.house}</td>
|
||||
<td>{row.nakshatra} · {row.pada}</td>
|
||||
<td>{row.nakshatraLord}</td>
|
||||
<td className={dignity?.step === 0 ? "chart-graha-bad" : dignity && dignity.step >= 6 ? "chart-graha-good" : undefined}>
|
||||
{dignity?.label ?? "—"}
|
||||
</td>
|
||||
<td className="chart-graha-karaka">{node ? "—" : karakaOf(row.name)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
</ChartSymbolHelp>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
@@ -632,7 +562,9 @@ export function ChartDataCard({
|
||||
let body: ReactNode;
|
||||
if (tab === "planets") {
|
||||
body = type === "D1" ? <PlanetTable view={view} board={board} /> : <ChartTypeTableBody view={view} type={type} />;
|
||||
} else if (tab === "upagraha" || tab === "special" || tab === "arudha") {
|
||||
} else if (tab === "special") {
|
||||
body = pointsReady ? <SpecialPointsPanel view={view} board={board} /> : <Waiting pending={pointsPending} failure={layerFailures?.get("points")} />;
|
||||
} else if (tab === "upagraha" || tab === "arudha") {
|
||||
body = pointsReady ? <PointTable view={view} board={board} group={tab} /> : <Waiting pending={pointsPending} failure={layerFailures?.get("points")} />;
|
||||
} else if (tab === "av") {
|
||||
body = avReady ? <AshtakavargaTable view={view} /> : <Waiting pending={pendingLayers?.has("ashtakavarga") ?? false} failure={layerFailures?.get("ashtakavarga")} />;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 盘面数据 engine columns (issue 163): Chara karaka ties, functional roles and
|
||||
* their identity labels, and the 特殊点 groups. Theme tokens only; muted, no
|
||||
* per-role colour. Cells stay on one line like the rest of the card.
|
||||
*/
|
||||
.chart-data-card .chart-identity-list { display: inline-flex; gap: 4px; white-space: nowrap; }
|
||||
.chart-data-card .chart-identity {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9px;
|
||||
background: color-mix(in srgb, var(--color-canvas-soft) 80%, transparent);
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.chart-data-card .chart-identity.is-pending { border-style: dashed; color: var(--color-ink-tertiary); background: transparent; }
|
||||
.chart-data-card .chart-functional-role.is-pending,
|
||||
.chart-data-card .chart-graha-table td.is-pending,
|
||||
.chart-data-card .chart-point-legacy { color: var(--color-ink-tertiary); }
|
||||
.chart-data-card .chart-functional-role.is-conflict {
|
||||
text-decoration: underline dotted color-mix(in srgb, var(--color-warning) 55%, transparent);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.chart-data-card .chart-graha-karaka.is-tied { color: var(--color-ink-tertiary); font-weight: 400; }
|
||||
.chart-data-card .chart-graha-table td.is-differs {
|
||||
color: var(--color-ink-strong);
|
||||
background: color-mix(in srgb, var(--color-warning) 8%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 盘面数据 · 行星 (D1): position, nakshatra, dignity, the Chara karaka role and
|
||||
* the functional columns (issue #163). Karaka roles and functional roles are
|
||||
* the engine's; a karaka role that bodies equal at full precision span reads
|
||||
* 「并列/角色待定」 and the other roles stay. Natural and functional nature
|
||||
* are separate columns; Yogakaraka, Badhaka and Kendradhipati sit beside the
|
||||
* functional role and never replace it.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { Table } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import { ChartSymbolHelp } from "@/components/chart-page/chart-symbol-help";
|
||||
import { AscendantStatusCell } from "@/components/chart-page/chart-vedic-tab";
|
||||
import type { ChartBoardState } from "@/hooks/use-chart-board";
|
||||
import { dignityStep } from "@/lib/chart-board-model";
|
||||
import {
|
||||
FUNCTIONAL_BASIS_TITLE,
|
||||
FUNCTIONAL_CONFLICT_TITLE,
|
||||
FUNCTIONAL_ZH,
|
||||
IDENTITY_HEAD_TITLE,
|
||||
IDENTITY_TITLE,
|
||||
identityLabel,
|
||||
KARAKA_SCHEME_NOTE,
|
||||
KARAKA_TIED,
|
||||
karakaCell,
|
||||
karakaTitle,
|
||||
NATURAL_CONDITION_TITLE,
|
||||
NATURAL_ZH,
|
||||
} from "@/lib/chart-data-terms";
|
||||
import type { ChartViewFunctional, ChartViewKarakaTable, ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import { planetZh } from "@/lib/chart-view-labels";
|
||||
import { dragScroll } from "@/lib/drag-scroll";
|
||||
import { planetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
type Scheme = "7" | "8";
|
||||
|
||||
function Wide({ children }: { children: ReactNode }) {
|
||||
return <div className="chart-graha-table-wrap chart-drag" onPointerDown={dragScroll}>{children}</div>;
|
||||
}
|
||||
|
||||
function KarakaCell({ table, name }: { table: ChartViewKarakaTable | null; name: string }) {
|
||||
if (!table) return <td>—</td>;
|
||||
const own = table.rows.find((row) => row.planet === name);
|
||||
if (own) return <td className="chart-graha-karaka" title={karakaTitle(own.key)}>{karakaCell(own.key)}</td>;
|
||||
const contested = table.rows.filter((row) => row.planet === null && row.candidates.includes(name));
|
||||
if (contested.length === 0) return <td>—</td>;
|
||||
const others = contested[0]!.candidates.filter((item) => item !== name).map(planetZh).join("、");
|
||||
return (
|
||||
<td className="chart-graha-karaka is-tied" title={`与${others}同度,${contested.map((row) => row.key).join(" / ")} 待定`}>
|
||||
{KARAKA_TIED}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function FunctionalCells({ row }: { row: ChartViewFunctional["rows"][number] | undefined }) {
|
||||
if (!row) return <><td>—</td><td>—</td><td>—</td><td>—</td></>;
|
||||
const natural = row.naturalCondition ? NATURAL_ZH[row.naturalCondition] : NATURAL_ZH[row.natural];
|
||||
const roleTitle = `${FUNCTIONAL_BASIS_TITLE[row.basis]}${row.conflict ? `;${FUNCTIONAL_CONFLICT_TITLE}` : ""}`;
|
||||
const roleClass = [
|
||||
"chart-functional-role",
|
||||
row.role === "pending" || row.role === "neutral" ? "is-pending" : null,
|
||||
row.conflict ? "is-conflict" : null,
|
||||
].filter(Boolean).join(" ");
|
||||
return (
|
||||
<>
|
||||
<td title={row.naturalCondition ? NATURAL_CONDITION_TITLE[row.naturalCondition] : undefined}>{natural}</td>
|
||||
<td className="chart-graha-num">{row.houses.length > 0 ? row.houses.join("、") : "—"}</td>
|
||||
<td className={roleClass} title={roleTitle}>{FUNCTIONAL_ZH[row.role]}</td>
|
||||
<td>
|
||||
{row.identities.length > 0 ? (
|
||||
<span className="chart-identity-list">
|
||||
{row.identities.map((item) => (
|
||||
<span
|
||||
key={item.key}
|
||||
className={item.key === "kendradhipati" ? "chart-identity is-pending" : "chart-identity"}
|
||||
title={IDENTITY_TITLE[item.key]}
|
||||
>
|
||||
{identityLabel(item.key, item.house)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : "—"}
|
||||
</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanetTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const [scheme, setScheme] = useState<Scheme>("7");
|
||||
const points = view.points?.status === "ok" ? view.points : null;
|
||||
const karakas = points?.karakaTables?.[scheme] ?? null;
|
||||
const eightReady = Boolean(points?.karakaTables?.["8"]);
|
||||
const functional = points?.functional ?? null;
|
||||
const functionalOf = (name: string) => functional?.rows.find((row) => row.name === name);
|
||||
const current = board.selection?.kind === "body" ? board.selection.key : null;
|
||||
const asc = view.vedic.ascendant;
|
||||
const ascOn = board.selection?.kind === "house" && board.model !== null && board.selection.house === board.model.ascendantHouse;
|
||||
const pickAsc = () => board.model && board.select(ascOn ? null : { kind: "house", house: board.model.ascendantHouse });
|
||||
// ChartSymbolHelp shows the ascendant cell's help bubble on hover, focus or tap.
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={scheme} onValueChange={(value) => value && setScheme(value as Scheme)}>
|
||||
<TabsList variant="default" aria-label="Chara Karaka 口径">
|
||||
<TabsTrigger value="7" className="chart-board-tab" title={KARAKA_SCHEME_NOTE["7"]}>七星制</TabsTrigger>
|
||||
<TabsTrigger value="8" className="chart-board-tab" title={KARAKA_SCHEME_NOTE["8"]} disabled={!eightReady}>八星制</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<ChartSymbolHelp resetKey="data-planets">
|
||||
<Wide>
|
||||
<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">宿主</th>
|
||||
<th scope="col">旺陷</th>
|
||||
<th scope="col" title={KARAKA_SCHEME_NOTE[scheme]}>Chara Karaka</th>
|
||||
<th scope="col">自然属性</th>
|
||||
<th scope="col">管辖宫位</th>
|
||||
<th scope="col" title={functional ? `${functional.profile} · ${functional.translation}` : undefined}>功能角色</th>
|
||||
<th scope="col" title={IDENTITY_HEAD_TITLE}>补充身份</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{asc ? (
|
||||
<tr className={ascOn ? "is-selected" : undefined} aria-selected={ascOn} onClick={pickAsc}>
|
||||
<th scope="row">{asc.label}</th>
|
||||
<td className="chart-graha-num">{asc.signLabel} {formatDegreeMinutes(asc.degreeInSign)}</td>
|
||||
<td className="chart-graha-num">1</td>
|
||||
<td>{asc.nakshatra ? `${asc.nakshatra}${asc.pada ? ` · ${asc.pada}` : ""}` : "—"}</td>
|
||||
<td>{asc.nakshatraLord ?? "—"}</td>
|
||||
<AscendantStatusCell />
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{view.vedic.planets.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const on = key !== null && current === key;
|
||||
const dignity = dignityStep(row.status);
|
||||
const node = key === "rahu" || key === "ketu";
|
||||
return (
|
||||
<tr
|
||||
key={row.name}
|
||||
className={on ? "is-selected" : undefined}
|
||||
aria-selected={on}
|
||||
onClick={() => key && board.select(on ? null : { kind: "body", key })}
|
||||
>
|
||||
<th scope="row">{row.label}</th>
|
||||
<td className="chart-graha-num">
|
||||
{row.signLabel} {formatDegreeMinutes(row.degreeInSign)}
|
||||
{row.retrograde && !node ? <span className="chart-graha-retro"> 逆</span> : null}
|
||||
</td>
|
||||
<td className="chart-graha-num">{row.house}</td>
|
||||
<td>{row.nakshatra} · {row.pada}</td>
|
||||
<td>{row.nakshatraLord}</td>
|
||||
<td className={dignity?.step === 0 ? "chart-graha-bad" : dignity && dignity.step >= 6 ? "chart-graha-good" : undefined}>
|
||||
{dignity?.label ?? "—"}
|
||||
</td>
|
||||
<KarakaCell table={karakas} name={row.name} />
|
||||
<FunctionalCells row={functionalOf(row.name)} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
</ChartSymbolHelp>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 盘面数据 · 特殊点 (issue #163): four groups — 特殊上升, 敏感点, Saham and
|
||||
* Goel 特殊点. The open group is the one the board's 特殊点 layer draws; a
|
||||
* row picks its point on the board. Every position is the engine's: sign-only
|
||||
* points (64th Navamsa, 22nd Drekkana) show the sign and its lord and no
|
||||
* degree. Saham lists the first nine and keeps the rest under 「全部」. Goel
|
||||
* uses the printed rule with the nodes and shows the worked example's pick
|
||||
* beside it.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import type { ChartBoardState } from "@/hooks/use-chart-board";
|
||||
import { specialRows } from "@/lib/chart-board-special";
|
||||
import {
|
||||
GOEL_POLICY_ZH,
|
||||
GOEL_SOURCE,
|
||||
SAHAM_LEGACY,
|
||||
SAHAM_SOURCE,
|
||||
SPECIAL_SETS,
|
||||
sahamFormulaText,
|
||||
sahamFormulaTitle,
|
||||
type SpecialSet,
|
||||
} from "@/lib/chart-data-terms";
|
||||
import type { ChartViewOk, ChartViewPoint } from "@/lib/chart-view-contract";
|
||||
import { dragScroll } from "@/lib/drag-scroll";
|
||||
|
||||
function Wide({ children }: { children: ReactNode }) {
|
||||
return <div className="chart-graha-table-wrap chart-drag" onPointerDown={dragScroll}>{children}</div>;
|
||||
}
|
||||
|
||||
function Missing() {
|
||||
return <p className="chart-page-note">这张表暂时没算出来。</p>;
|
||||
}
|
||||
|
||||
function NameCell({ point, on, pick }: { point: ChartViewPoint; on: boolean; pick: () => void }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function position(point: ChartViewPoint): string {
|
||||
return point.degreeInSign !== null ? `${point.signLabel} ${formatDegreeMinutes(point.degreeInSign)}` : point.signLabel;
|
||||
}
|
||||
|
||||
type Column = { head: string; title?: string; num?: boolean; cell: (point: ChartViewPoint) => ReactNode };
|
||||
|
||||
function PointRows({ rows, board, columns }: { rows: ChartViewPoint[]; board: ChartBoardState; columns: Column[] }) {
|
||||
const picked = board.selection?.kind === "point" ? board.selection.key : null;
|
||||
return (
|
||||
<Wide>
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">名称</th>
|
||||
{columns.map((column) => <th key={column.head} scope="col" title={column.title}>{column.head}</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}>
|
||||
<NameCell point={point} on={on} pick={pick} />
|
||||
{columns.map((column) => <td key={column.head} className={column.num ? "chart-graha-num" : undefined}>{column.cell(point)}</td>)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
);
|
||||
}
|
||||
|
||||
const NAKSHATRA_COLUMNS: Column[] = [
|
||||
{ head: "月宿", cell: (point) => point.nakshatra ?? "—" },
|
||||
{ head: "宿主", cell: (point) => point.nakshatraLord ?? "—" },
|
||||
{ head: "足", num: true, cell: (point) => point.pada ?? "—" },
|
||||
];
|
||||
|
||||
const LAGNA_COLUMNS: Column[] = [
|
||||
{ head: "黄道经度", num: true, cell: position },
|
||||
{ head: "宫位", num: true, cell: (point) => `${point.house}宫` },
|
||||
...NAKSHATRA_COLUMNS,
|
||||
{ head: "传统含义", cell: (point) => <span className="chart-point-note">{point.note}</span> },
|
||||
];
|
||||
|
||||
const SENSITIVE_COLUMNS: Column[] = [
|
||||
{ head: "位置", num: true, cell: position },
|
||||
{ head: "宫位", num: true, cell: (point) => `${point.house}宫` },
|
||||
{ head: "星座主", cell: (point) => point.signLord ?? "—" },
|
||||
{ head: "参照", cell: (point) => point.reference ?? "—" },
|
||||
...NAKSHATRA_COLUMNS,
|
||||
];
|
||||
|
||||
const SAHAM_COLUMNS: Column[] = [
|
||||
{ head: "黄道经度", num: true, cell: position },
|
||||
{ head: "宫位", num: true, cell: (point) => `${point.house}宫` },
|
||||
{ head: "公式", title: SAHAM_SOURCE, cell: (point) => (point.formula
|
||||
? <span title={sahamFormulaTitle(point.formula)}>{sahamFormulaText(point.formula)}</span>
|
||||
: <span className="chart-point-legacy">{SAHAM_LEGACY}</span>) },
|
||||
...NAKSHATRA_COLUMNS,
|
||||
];
|
||||
|
||||
function GoelTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const goel = view.points?.status === "ok" ? view.points.goel ?? null : null;
|
||||
if (!goel) return <Missing />;
|
||||
const points = specialRows(view, "goel") ?? [];
|
||||
const picked = board.selection?.kind === "point" ? board.selection.key : null;
|
||||
const strength = goel.strength;
|
||||
const strengthTitle = strength
|
||||
? `上升 / 七宫星座力:${GOEL_POLICY_ZH.text} ${strength.text.lagna} / ${strength.text.seventh};${GOEL_POLICY_ZH.example} ${strength.example.lagna} / ${strength.example.seventh}`
|
||||
: undefined;
|
||||
return (
|
||||
<Wide>
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">名称</th>
|
||||
<th scope="col" title={GOEL_SOURCE}>所取星 · {GOEL_POLICY_ZH.text}</th>
|
||||
<th scope="col">位置</th>
|
||||
<th scope="col">宫位</th>
|
||||
<th scope="col" title={strengthTitle}>{GOEL_POLICY_ZH.example}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{goel.rows.map((row) => {
|
||||
const point = points.find((item) => item.key === row.key) ?? null;
|
||||
const on = picked === row.key;
|
||||
const pick = () => point && board.pickPoint(on ? null : point);
|
||||
return (
|
||||
<tr key={row.key} className={on ? "is-selected" : undefined} aria-selected={on} onClick={pick}>
|
||||
{point ? <NameCell point={point} on={on} pick={pick} /> : <th scope="row">{row.label}</th>}
|
||||
<td className={row.planet ? "chart-graha-karaka" : "is-pending"} title={row.rule ?? undefined}>{row.planet ?? "无唯一取星"}</td>
|
||||
<td className="chart-graha-num">{row.sign ?? "—"}</td>
|
||||
<td className="chart-graha-num">{row.house ? `${row.house}宫` : "—"}</td>
|
||||
<td className={row.differs ? "is-differs" : undefined} title={row.differs ? strengthTitle : undefined}>
|
||||
{row.differs ? (row.comparePlanet ?? "无唯一取星") : "相同"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMNS: Readonly<Record<Exclude<SpecialSet, "saham" | "goel">, Column[]>> = {
|
||||
lagna: LAGNA_COLUMNS,
|
||||
sensitive: SENSITIVE_COLUMNS,
|
||||
};
|
||||
|
||||
export function SpecialPointsPanel({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const [sahamAll, setSahamAll] = useState(false);
|
||||
const set = board.specialSet;
|
||||
const rows = specialRows(view, set);
|
||||
let body: ReactNode;
|
||||
if (set === "goel") body = <GoelTable view={view} board={board} />;
|
||||
else if (!rows || rows.length === 0) body = <Missing />;
|
||||
else if (set === "saham") body = <PointRows rows={sahamAll ? rows : rows.filter((point) => point.primary)} board={board} columns={SAHAM_COLUMNS} />;
|
||||
else body = <PointRows rows={rows} board={board} columns={COLUMNS[set]} />;
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={set} onValueChange={(value) => value && board.setSpecialSet(value as SpecialSet)}>
|
||||
<TabsList variant="default" aria-label="特殊点分组" className="chart-data-subtabs" onPointerDown={dragScroll}>
|
||||
{SPECIAL_SETS.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{set === "saham" && rows && rows.length > 0 ? (
|
||||
<Tabs value={sahamAll ? "all" : "first"} onValueChange={(value) => value && setSahamAll(value === "all")}>
|
||||
<TabsList variant="default" aria-label="Saham 范围">
|
||||
<TabsTrigger value="first" className="chart-board-tab">首批九项</TabsTrigger>
|
||||
<TabsTrigger value="all" className="chart-board-tab">全部 <small>{rows.length}</small></TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
) : null}
|
||||
</div>
|
||||
{body}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
boardSource,
|
||||
pointSelection,
|
||||
pointsOf,
|
||||
stepPoint,
|
||||
type ChartBoardLayer,
|
||||
type PointGroup,
|
||||
} from "@/lib/chart-board-view";
|
||||
import { layerPoints, stepPointInSet } from "@/lib/chart-board-special";
|
||||
import type { SpecialSet } from "@/lib/chart-data-terms";
|
||||
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";
|
||||
@@ -57,6 +58,8 @@ export function useChartBoard({
|
||||
const [layer, setLayerState] = useState<ChartBoardLayer>("none");
|
||||
/** 标点 layer: every point of one group drawn on the board at once (issue #114). */
|
||||
const [pointLayer, setPointLayerState] = useState<PointGroup | "none">("none");
|
||||
/** The 特殊点 group the data card has open; the special layer draws only it (issue #163). */
|
||||
const [specialSet, setSpecialSet] = useState<SpecialSet>("lagna");
|
||||
|
||||
useEffect(() => {
|
||||
hydrateStyle();
|
||||
@@ -96,7 +99,7 @@ export function useChartBoard({
|
||||
if (!model) return;
|
||||
setSelection((current) => {
|
||||
if (current?.kind !== "point") return stepSelection(model, current, direction);
|
||||
const next = stepPoint(view, current.group, current.key, direction);
|
||||
const next = stepPointInSet(view, current.group, current.key, direction);
|
||||
return next ? pointSelection(next) : current;
|
||||
});
|
||||
}, [model, view]);
|
||||
@@ -104,12 +107,12 @@ export function useChartBoard({
|
||||
/** 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 layerRows = pointLayer === "none" ? [] : layerPoints(view, pointLayer, specialSet);
|
||||
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]);
|
||||
}, [pointLayer, selection, specialSet, type, view]);
|
||||
|
||||
const selectPoint = useCallback((key: string) => {
|
||||
const group = pointLayer !== "none" && pointsOf(view, pointLayer)?.some((row) => row.key === key)
|
||||
@@ -159,6 +162,8 @@ export function useChartBoard({
|
||||
onKeyDown,
|
||||
pointLayer,
|
||||
setPointLayer,
|
||||
specialSet,
|
||||
setSpecialSet,
|
||||
pointMarks,
|
||||
selectPoint,
|
||||
pickPoint,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The 特殊点 groups on the board (issue #163). 特殊点 holds four groups; the
|
||||
* board layer draws the one open in the data card, and stepping through
|
||||
* points stays inside the picked point's group. Saham shows its first nine on
|
||||
* the board; a point picked from 「全部」 is still drawn as the picked one.
|
||||
*/
|
||||
import { pointsOf, type PointGroup } from "./chart-board-view.ts";
|
||||
import type { SpecialSet } from "./chart-data-terms.ts";
|
||||
import type { ChartViewOk, ChartViewPoint } from "./chart-view-contract.ts";
|
||||
|
||||
export function pointSet(point: ChartViewPoint): SpecialSet {
|
||||
return point.set ?? "lagna";
|
||||
}
|
||||
|
||||
/** One group of 特殊点, or null while the points layer is missing. */
|
||||
export function specialRows(view: ChartViewOk, set: SpecialSet): ChartViewPoint[] | null {
|
||||
const rows = pointsOf(view, "special");
|
||||
return rows ? rows.filter((point) => pointSet(point) === set) : null;
|
||||
}
|
||||
|
||||
/** The points a board layer draws. */
|
||||
export function layerPoints(view: ChartViewOk, group: PointGroup, set: SpecialSet): ChartViewPoint[] {
|
||||
if (group !== "special") return pointsOf(view, group) ?? [];
|
||||
return (specialRows(view, set) ?? []).filter((point) => point.primary !== false);
|
||||
}
|
||||
|
||||
/** Next / previous point in the same table (for 特殊点, the same group). */
|
||||
export function stepPointInSet(view: ChartViewOk, group: PointGroup, key: string, direction: 1 | -1): ChartViewPoint | null {
|
||||
const all = pointsOf(view, group);
|
||||
if (!all || all.length === 0) return null;
|
||||
const current = all.find((point) => point.key === key);
|
||||
const rows = group === "special" && current ? all.filter((point) => pointSet(point) === pointSet(current)) : all;
|
||||
const index = rows.findIndex((point) => point.key === key);
|
||||
return rows[(Math.max(0, index) + direction + rows.length) % rows.length] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Names and short glosses for the 盘面数据 card's engine columns (issue #163):
|
||||
* Chara karaka roles, functional roles, sensitive points, Sahams and Goel's
|
||||
* special points. Labels only; every value they describe comes from the
|
||||
* engine. The Chinese gloss is a reading aid, not the role's whole meaning.
|
||||
*/
|
||||
import type { CharaKarakaKey } from "./chart-view-contract.ts";
|
||||
|
||||
export type KarakaTerm = { gloss: string; sanskrit: string; meaning: string };
|
||||
|
||||
export const KARAKA_TERMS: Readonly<Record<CharaKarakaKey, KarakaTerm>> = {
|
||||
AK: { gloss: "自我", sanskrit: "Atmakaraka", meaning: "灵魂与核心自我" },
|
||||
AmK: { gloss: "职事", sanskrit: "Amatyakaraka", meaning: "职事、辅佐与谋生" },
|
||||
BK: { gloss: "手足", sanskrit: "Bhratrikaraka", meaning: "兄弟姐妹、同辈与勇气" },
|
||||
MK: { gloss: "母亲", sanskrit: "Matrikaraka", meaning: "母亲、家庭与根基" },
|
||||
PiK: { gloss: "父亲", sanskrit: "Pitrikaraka", meaning: "父亲与长辈" },
|
||||
PK: { gloss: "子女", sanskrit: "Putrakaraka", meaning: "子女、学生与创造" },
|
||||
GK: { gloss: "障碍", sanskrit: "Gnatikaraka", meaning: "亲族纷争、对手、疾病与障碍" },
|
||||
DK: { gloss: "配偶", sanskrit: "Darakaraka", meaning: "配偶与合作伙伴" },
|
||||
};
|
||||
|
||||
export const KARAKA_SCHEME_NOTE: Readonly<Record<"7" | "8", string>> = {
|
||||
"7": "七星制:太阳至土星七颗星,按未取整的星座内度数由高到低排;罗睺、计都不计",
|
||||
"8": "八星制:七星加罗睺,罗睺按 30° 减星座内度数排;计都不计",
|
||||
};
|
||||
|
||||
/** Cell text for a role, e.g. 「AK · 自我」. */
|
||||
export function karakaCell(key: CharaKarakaKey): string {
|
||||
return `${key} · ${KARAKA_TERMS[key].gloss}`;
|
||||
}
|
||||
|
||||
export function karakaTitle(key: CharaKarakaKey): string {
|
||||
const term = KARAKA_TERMS[key];
|
||||
return `${term.sanskrit}(${key}):${term.meaning}`;
|
||||
}
|
||||
|
||||
export const KARAKA_TIED = "并列/角色待定";
|
||||
|
||||
export const NATURAL_ZH = {
|
||||
benefic: "吉星",
|
||||
malefic: "凶星",
|
||||
phase: "吉星 · 随月相",
|
||||
association: "吉星 · 随同宫",
|
||||
} as const;
|
||||
|
||||
export const NATURAL_CONDITION_TITLE = {
|
||||
phase: "月亮的自然吉凶随月相而定,本表没有判定",
|
||||
association: "水星的自然吉凶随同宫星而定,本表没有判定",
|
||||
} as const;
|
||||
|
||||
export const FUNCTIONAL_ZH = {
|
||||
benefic: "偏吉/支持",
|
||||
malefic: "偏凶/挑战",
|
||||
neutral: "混合/待综合",
|
||||
pending: "待综合",
|
||||
} as const;
|
||||
|
||||
export const FUNCTIONAL_BASIS_TITLE = {
|
||||
general: "BPHS 第34章宫主总则;关联条件未判断",
|
||||
exception: "BPHS 第34章该上升的明文例外",
|
||||
node: "依落宫、定位星及关联条件判断;本表未执行关联判断",
|
||||
} as const;
|
||||
|
||||
export const FUNCTIONAL_CONFLICT_TITLE = "与自然属性方向相反,两层分开看";
|
||||
|
||||
export const IDENTITY_HEAD_TITLE =
|
||||
"Yogakaraka:同一星分别主管 4/7/10 宫之一与 5/9 宫之一。Badhaka:动/固/双星座上升分别取 11/9/7 宫主。两者都不改变功能角色";
|
||||
|
||||
export function identityLabel(key: "yogakaraka" | "badhaka" | "kendradhipati", house: number | null): string {
|
||||
if (key === "yogakaraka") return "Yogakaraka";
|
||||
if (key === "badhaka") return `Badhaka · ${house ?? "—"}宫主`;
|
||||
return "Kendradhipati 待综合";
|
||||
}
|
||||
|
||||
export const IDENTITY_TITLE = {
|
||||
yogakaraka: "严格 Yogakaraka:分别主管一个角宫(4/7/10)和一个三方宫(5/9)",
|
||||
badhaka: "Badhaka 宫主:独立标签,不改变功能角色",
|
||||
kendradhipati: "自然吉星主管角宫且不管 1/5/9 宫;Kendradhipati Dosha 的成立条件尚未判断",
|
||||
} as const;
|
||||
|
||||
export type SpecialSet = "lagna" | "sensitive" | "saham" | "goel";
|
||||
|
||||
export const SPECIAL_SETS: ReadonlyArray<readonly [SpecialSet, string]> = [
|
||||
["lagna", "特殊上升"],
|
||||
["sensitive", "敏感点"],
|
||||
["saham", "Saham"],
|
||||
["goel", "Goel 特殊点"],
|
||||
];
|
||||
|
||||
export type PointTerm = { label: string; short: string; english: string; note: string };
|
||||
|
||||
export const SENSITIVE_TERMS: Readonly<Record<string, PointTerm>> = {
|
||||
Bhrigu_Bindu: { label: "Bhrigu 点", short: "BB", english: "Bhrigu Bindu", note: "罗睺到月亮顺行弧的中点,传统上用来看行运触发" },
|
||||
Navamsa_64: { label: "第64 Navamsa", short: "N64", english: "64th Navamsa", note: "从月亮所在九分盘星座数起的第64分,传统敏感位置" },
|
||||
Drekkana_22: { label: "第22 Drekkana", short: "D22", english: "22nd Drekkana", note: "从上升所在三分盘星座数起的第22分,传统敏感位置" },
|
||||
};
|
||||
|
||||
/** PVR Table 74 meanings, as short Chinese glosses; the key is the engine's rule name. */
|
||||
export const SAHAM_TERMS: Readonly<Record<string, { gloss: string; short: string }>> = {
|
||||
Punya_Saham: { gloss: "福德", short: "福" },
|
||||
Vidya_Saham: { gloss: "学业", short: "学" },
|
||||
Yashas_Saham: { gloss: "声誉", short: "誉" },
|
||||
Mitra_Saham: { gloss: "朋友", short: "友" },
|
||||
Mahatmya_Saham: { gloss: "卓越", short: "卓" },
|
||||
Asha_Saham: { gloss: "愿望", short: "愿" },
|
||||
Samartha_Saham: { gloss: "能力与进取", short: "能" },
|
||||
Bhratri_Saham: { gloss: "兄弟", short: "兄" },
|
||||
Gaurava_Saham: { gloss: "尊重", short: "尊" },
|
||||
Pitri_Saham: { gloss: "父亲", short: "父" },
|
||||
Rajya_Saham: { gloss: "王权", short: "权" },
|
||||
Matri_Saham: { gloss: "母亲", short: "母" },
|
||||
Putra_Saham: { gloss: "子女", short: "子" },
|
||||
Jeeva_Saham: { gloss: "生命", short: "命" },
|
||||
Karma_Saham: { gloss: "行动与工作", short: "行" },
|
||||
Roga_Saham: { gloss: "疾病", short: "病" },
|
||||
Kali_Saham: { gloss: "大不顺", short: "厄" },
|
||||
Sastra_Saham: { gloss: "学问", short: "典" },
|
||||
Bandhu_Saham: { gloss: "亲属", short: "亲" },
|
||||
Mrityu_Saham: { gloss: "终结", short: "终" },
|
||||
Paradesa_Saham: { gloss: "异地", short: "异" },
|
||||
Artha_Saham: { gloss: "财物", short: "财" },
|
||||
Paradara_Saham: { gloss: "婚外关系", short: "外" },
|
||||
Vanik_Saham: { gloss: "商业", short: "商" },
|
||||
Karyasiddhi_Saham: { gloss: "事项成就", short: "成" },
|
||||
Vivaha_Saham: { gloss: "婚姻", short: "婚" },
|
||||
Santapa_Saham: { gloss: "忧伤", short: "忧" },
|
||||
Sraddha_Saham: { gloss: "虔诚", short: "虔" },
|
||||
Preeti_Saham: { gloss: "情感依恋", short: "情" },
|
||||
Jadya_Saham: { gloss: "慢性病", short: "慢" },
|
||||
Vyapara_Saham: { gloss: "经营", short: "营" },
|
||||
Satru_Saham: { gloss: "敌人", short: "敌" },
|
||||
Jalapatana_Saham: { gloss: "渡海", short: "渡" },
|
||||
Bandhana_Saham: { gloss: "束缚", short: "缚" },
|
||||
Apamrityu_Saham: { gloss: "意外终结", short: "意" },
|
||||
Labha_Saham: { gloss: "收益", short: "益" },
|
||||
};
|
||||
|
||||
export const SAHAM_SOURCE = "PVR Narasimha Rao《Vedic Astrology: An Integrated Approach》§28.8.1 表74";
|
||||
export const SAHAM_LEGACY = "旧式候选,未核对来源";
|
||||
|
||||
const OPERAND_ZH: Readonly<Record<string, string>> = {
|
||||
Sun: "日", Moon: "月", Mars: "火", Mercury: "水", Jupiter: "木", Venus: "金", Saturn: "土",
|
||||
Ascendant: "升", Cancer15: "巨蟹15°", SunSignLord: "日座主", MoonSignLord: "月座主",
|
||||
};
|
||||
|
||||
export function sahamOperand(operand: string): string {
|
||||
const house = /^House(\d+)(Cusp|Lord)$/.exec(operand);
|
||||
if (house) return `${house[1]}宫${house[2] === "Lord" ? "主" : ""}`;
|
||||
if (OPERAND_ZH[operand]) return OPERAND_ZH[operand]!;
|
||||
return SAHAM_TERMS[operand] ? `${SAHAM_TERMS[operand]!.gloss}点` : operand;
|
||||
}
|
||||
|
||||
/** 「日 · 月 − 日 + 升」 plus 「+30°」 when the correction applied. */
|
||||
export function sahamFormulaText(formula: { operands: readonly string[]; variant: "day" | "night"; plus30: boolean }): string {
|
||||
const [a, b, c] = formula.operands.map(sahamOperand);
|
||||
return `${formula.variant === "day" ? "日" : "夜"} · ${a} − ${b} + ${c}${formula.plus30 ? " +30°" : ""}`;
|
||||
}
|
||||
|
||||
export function sahamFormulaTitle(formula: { variant: "day" | "night"; plus30: boolean }): string {
|
||||
const time = formula.variant === "day" ? "白天出生,用日间公式" : "夜间出生,用该项的夜间公式";
|
||||
const arc = formula.plus30 ? "第三项不在第二项到第一项的顺行弧上,加 30°" : "第三项在第二项到第一项的顺行弧上,不加 30°";
|
||||
return `${time};${arc}`;
|
||||
}
|
||||
|
||||
export const GOEL_TERMS: Readonly<Record<string, PointTerm>> = {
|
||||
Goel_Brahma: { label: "Brahma", short: "梵", english: "Goel · Brahma", note: "Jaimini 寿限判断里的 Brahma,按 Goel 的规则取星" },
|
||||
Goel_Maheshwara: { label: "Maheshwara", short: "自在", english: "Goel · Maheshwara", note: "Jaimini 寿限判断里的 Maheshwara,按 Goel 的规则取星" },
|
||||
Goel_Rudra: { label: "Rudra", short: "楼陀", english: "Goel · Rudra", note: "Jaimini 寿限判断里的 Rudra,按 Goel 的规则取星" },
|
||||
};
|
||||
|
||||
export const GOEL_POLICY_ZH = { text: "书面规则含交点", example: "示例排除交点" } as const;
|
||||
export const GOEL_SOURCE = "V. P. Goel《Predicting through Jaimini Astrology》第10–17页;Karaka 仍按七星制";
|
||||
@@ -337,6 +337,23 @@ export const chartViewPointSchema = z.object({
|
||||
sourceHouse: z.number().int().min(1).max(12).nullable(),
|
||||
sourceLord: z.string().nullable(),
|
||||
note: z.string().min(1),
|
||||
/**
|
||||
* 特殊点 only (issue #163): which of the four groups the row belongs to.
|
||||
* Absent means the special lagnas and Yogi points.
|
||||
*/
|
||||
set: z.enum(["lagna", "sensitive", "saham", "goel"]).optional(),
|
||||
/** Lord of the point's sign, for sign-only points (64th Navamsa, 22nd Drekkana). */
|
||||
signLord: z.string().nullable().optional(),
|
||||
/** What the point is counted from, as the engine reported it. */
|
||||
reference: z.string().nullable().optional(),
|
||||
/** Saham: one of the nine shown first; the rest sit under 「全部」. */
|
||||
primary: z.boolean().optional(),
|
||||
/** Saham: Table 74 operands A − B + C, the day/night variant and the +30° correction. */
|
||||
formula: z.object({
|
||||
operands: z.array(z.string().min(1)).length(3),
|
||||
variant: z.enum(["day", "night"]),
|
||||
plus30: z.boolean(),
|
||||
}).nullable().optional(),
|
||||
});
|
||||
export const chartViewFriendshipSchema = z.object({
|
||||
/** Planet → target → relation; natural and temporary −1/0/1, compound −2…2. */
|
||||
@@ -352,10 +369,58 @@ export const chartViewFriendshipSchema = z.object({
|
||||
})),
|
||||
});
|
||||
export const CHARA_KARAKA_KEYS = ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"] as const;
|
||||
/** Seven-planet Chara karakas, highest in-sign degree first; null on a tie. */
|
||||
export const CHARA_KARAKA_8_KEYS = ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"] as const;
|
||||
export type CharaKarakaKey = (typeof CHARA_KARAKA_8_KEYS)[number];
|
||||
/**
|
||||
* One Chara karaka role, highest unrounded in-sign degree first. A role that
|
||||
* bodies equal at full precision span has no planet and lists the candidates.
|
||||
*/
|
||||
export const chartViewKarakaSchema = z.object({
|
||||
key: z.enum(CHARA_KARAKA_KEYS),
|
||||
planet: z.string().min(1),
|
||||
key: z.enum(CHARA_KARAKA_8_KEYS),
|
||||
planet: z.string().min(1).nullable(),
|
||||
candidates: z.array(z.string().min(1)),
|
||||
});
|
||||
export const chartViewKarakaTableSchema = z.object({
|
||||
scheme: z.enum(["7", "8"]),
|
||||
rows: z.array(chartViewKarakaSchema),
|
||||
});
|
||||
/** 行星表 functional columns: the engine's v2 ownership profile, one row per body. */
|
||||
export const chartViewFunctionalSchema = z.object({
|
||||
profile: z.string().min(1),
|
||||
translation: z.string().min(1),
|
||||
rows: z.array(z.object({
|
||||
name: z.string().min(1),
|
||||
natural: z.enum(["benefic", "malefic"]),
|
||||
/** Moon / Mercury: the condition on their natural nature, never evaluated here. */
|
||||
naturalCondition: z.enum(["phase", "association"]).nullable(),
|
||||
houses: z.array(z.number().int().min(1).max(12)),
|
||||
/** `neutral` is mixed / association-dependent; `pending` is the nodes, not judged. */
|
||||
role: z.enum(["benefic", "malefic", "neutral", "pending"]),
|
||||
basis: z.enum(["general", "exception", "node"]),
|
||||
identities: z.array(z.object({
|
||||
key: z.enum(["yogakaraka", "badhaka", "kendradhipati"]),
|
||||
house: z.number().int().min(1).max(12).nullable(),
|
||||
})),
|
||||
conflict: z.boolean(),
|
||||
})),
|
||||
});
|
||||
/** Goel's Brahma / Maheshwara / Rudra under the printed text, with the worked example beside it. */
|
||||
export const chartViewGoelSchema = z.object({
|
||||
rows: z.array(z.object({
|
||||
key: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
planet: z.string().nullable(),
|
||||
comparePlanet: z.string().nullable(),
|
||||
differs: z.boolean(),
|
||||
sign: z.string().nullable(),
|
||||
house: z.number().int().min(1).max(12).nullable(),
|
||||
rule: z.string().nullable(),
|
||||
})),
|
||||
/** Lagna and 7th sign strength under each policy (Brahma picks its anchor from them). */
|
||||
strength: z.object({
|
||||
text: z.object({ lagna: z.number(), seventh: z.number() }),
|
||||
example: z.object({ lagna: z.number(), seventh: z.number() }),
|
||||
}).nullable(),
|
||||
});
|
||||
export const chartViewPointsOkSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
@@ -363,8 +428,15 @@ export const chartViewPointsOkSchema = z.object({
|
||||
special: z.array(chartViewPointSchema).nullable(),
|
||||
arudha: z.array(chartViewPointSchema).nullable(),
|
||||
friendship: chartViewFriendshipSchema.nullable(),
|
||||
// Optional so a points layer cached before the karaka column still parses.
|
||||
karakas: z.array(chartViewKarakaSchema).nullable().optional(),
|
||||
// Optional so a points layer cached before these columns still parses.
|
||||
karakaTables: z.object({
|
||||
"7": chartViewKarakaTableSchema.nullable(),
|
||||
"8": chartViewKarakaTableSchema.nullable(),
|
||||
}).optional(),
|
||||
functional: chartViewFunctionalSchema.nullable().optional(),
|
||||
goel: chartViewGoelSchema.nullable().optional(),
|
||||
/** Saham day/night at the birth time and place; null when the engine did not say. */
|
||||
sahamDaytime: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -521,6 +593,9 @@ export type ChartViewAshtakavargaOk = z.infer<typeof chartViewAshtakavargaOkSche
|
||||
export type ChartViewPointsOk = z.infer<typeof chartViewPointsOkSchema>;
|
||||
export type ChartViewPoint = z.infer<typeof chartViewPointSchema>;
|
||||
export type ChartViewKaraka = z.infer<typeof chartViewKarakaSchema>;
|
||||
export type ChartViewKarakaTable = z.infer<typeof chartViewKarakaTableSchema>;
|
||||
export type ChartViewFunctional = z.infer<typeof chartViewFunctionalSchema>;
|
||||
export type ChartViewGoel = z.infer<typeof chartViewGoelSchema>;
|
||||
|
||||
export const CHART_VIEW_TABS = [
|
||||
{ id: "vedic", label: "星盘" },
|
||||
|
||||
@@ -13,9 +13,8 @@ import {
|
||||
signZh,
|
||||
} from "./chart-view-labels.ts";
|
||||
import { panchangaNameZh } from "./panchanga-names-zh.ts";
|
||||
import { CHARA_KARAKA_KEYS } from "./chart-view-contract.ts";
|
||||
import { mapFunctional, mapGoel, mapKarakaTables, mapSahams, mapSensitive } from "./chart-view-points-engine.ts";
|
||||
import type {
|
||||
ChartViewKaraka,
|
||||
ChartViewPoint,
|
||||
ChartViewPointsOk,
|
||||
ChartViewAshtakavargaOk,
|
||||
@@ -381,18 +380,6 @@ function mapFriendship(block: unknown): ChartViewPointsOk["friendship"] {
|
||||
return rows.length > 0 ? { rows } : null;
|
||||
}
|
||||
|
||||
function karakaRows(block: unknown): ChartViewKaraka[] | null {
|
||||
const packet = record(block);
|
||||
if (!packet || packet.status !== "computed" || !Array.isArray(packet.rows)) return null;
|
||||
const rows = packet.rows.flatMap((raw): ChartViewKaraka[] => {
|
||||
const row = record(raw);
|
||||
const key = CHARA_KARAKA_KEYS.find((item) => item === text(row?.key));
|
||||
const planet = text(row?.planet);
|
||||
return key && planet ? [{ key, planet }] : [];
|
||||
});
|
||||
return rows.length === CHARA_KARAKA_KEYS.length ? rows : null;
|
||||
}
|
||||
|
||||
export function unavailablePoints(reason: string): ChartViewUnavailableLayer {
|
||||
return { status: "unavailable", reason, copy: "这几张表暂时没算出来,稍后再试。" };
|
||||
}
|
||||
@@ -402,13 +389,20 @@ export function mapPoints(packet: Record<string, unknown> | null): ChartViewPoin
|
||||
if (!packet || packet.success === false) return unavailablePoints("points_missing");
|
||||
const special = pointRows(packet.special_lagnas, "special");
|
||||
const yogi = pointRows(packet.yogi_points, "special");
|
||||
// 特殊点 groups (issue #163): 特殊上升 rows carry no `set`; the others name theirs.
|
||||
const sahams = mapSahams(packet.sahams);
|
||||
const goel = mapGoel(packet.goel_points);
|
||||
const specialRows = [...(special ?? []), ...(yogi ?? []), ...(mapSensitive(packet.sensitive_points) ?? []), ...(sahams?.rows ?? []), ...(goel?.points ?? [])];
|
||||
const layer = {
|
||||
status: "ok" as const,
|
||||
upagrahas: pointRows(packet.upagrahas, "upagraha"),
|
||||
special: special || yogi ? [...(special ?? []), ...(yogi ?? [])] : null,
|
||||
special: specialRows.length > 0 ? specialRows : null,
|
||||
arudha: arudhaRows(packet.arudha_padas),
|
||||
friendship: mapFriendship(packet.friendship),
|
||||
karakas: karakaRows(packet.chara_karakas),
|
||||
karakaTables: mapKarakaTables(packet),
|
||||
functional: mapFunctional(packet.functional_roles),
|
||||
goel: goel?.goel ?? null,
|
||||
sahamDaytime: sahams?.daytime ?? null,
|
||||
};
|
||||
if (!layer.upagrahas && !layer.special && !layer.arudha && !layer.friendship) return unavailablePoints("points_all_blocked");
|
||||
return layer;
|
||||
|
||||
@@ -252,9 +252,10 @@ export async function assembleChartView(input: ChartViewLoadInput): Promise<{
|
||||
bhavaBala: enginePayload(houses),
|
||||
}))
|
||||
: null;
|
||||
// Upagrahas need the birth moment and place; followUp carries both.
|
||||
// Upagrahas need the birth moment and place; followUp carries both. Both
|
||||
// karaka schemes come back so the 七星 / 八星 switch needs no second call.
|
||||
const points = layers.has("points")
|
||||
? await callEngine(input.postEngine, "/api/chart_points", followUp).then(layerPayload("points"))
|
||||
? await callEngine(input.postEngine, "/api/chart_points", { ...followUp, karaka_scheme: "both" }).then(layerPayload("points"))
|
||||
: null;
|
||||
await dashaReady;
|
||||
const lagnaPrecheck = await lagnaReady;
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* The points layer's engine columns (issue #163): Chara karaka tables in both
|
||||
* schemes, the functional role table, and the 敏感点 / Saham / Goel groups of
|
||||
* 特殊点. Reads and renames what the engine returned; nothing is computed.
|
||||
* A block the engine blocked or left out is null, never filled in.
|
||||
*/
|
||||
import { asRecord as record } from "@/lib/guards";
|
||||
|
||||
import {
|
||||
CHARA_KARAKA_8_KEYS,
|
||||
CHARA_KARAKA_KEYS,
|
||||
type CharaKarakaKey,
|
||||
type ChartViewFunctional,
|
||||
type ChartViewGoel,
|
||||
type ChartViewKarakaTable,
|
||||
type ChartViewPoint,
|
||||
} from "./chart-view-contract.ts";
|
||||
import { GOEL_TERMS, SAHAM_LEGACY, SAHAM_TERMS, SENSITIVE_TERMS, type PointTerm } from "./chart-data-terms.ts";
|
||||
import { PLANET_ORDER, isSignName, planetZh, signZh } from "./chart-view-labels.ts";
|
||||
|
||||
function finite(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function computed(block: unknown): Record<string, unknown> | null {
|
||||
const packet = record(block);
|
||||
return packet && packet.status === "computed" ? packet : null;
|
||||
}
|
||||
|
||||
function houseOf(value: unknown): number | null {
|
||||
const house = finite(value);
|
||||
return house !== null && Number.isInteger(house) && house >= 1 && house <= 12 ? house : null;
|
||||
}
|
||||
|
||||
/** One scheme's table; null unless every role of the scheme is present. */
|
||||
export function mapKarakaTable(block: unknown, scheme: "7" | "8"): ChartViewKarakaTable | null {
|
||||
const packet = computed(block);
|
||||
if (!packet || packet.scheme !== `chara_${scheme}` || !Array.isArray(packet.rows)) return null;
|
||||
const keys: readonly CharaKarakaKey[] = scheme === "7" ? CHARA_KARAKA_KEYS : CHARA_KARAKA_8_KEYS;
|
||||
const rows = packet.rows.flatMap((raw) => {
|
||||
const row = record(raw);
|
||||
const key = keys.find((item) => item === text(row?.key));
|
||||
if (!row || !key) return [];
|
||||
const planet = row.tied === true ? null : text(row.planet);
|
||||
const candidates = Array.isArray(row.candidates) ? row.candidates.filter((item): item is string => typeof item === "string" && item.length > 0) : [];
|
||||
if (planet === null && candidates.length < 2) return [];
|
||||
return [{ key, planet, candidates }];
|
||||
});
|
||||
return rows.length === keys.length && rows.every((row, index) => row.key === keys[index]) ? { scheme, rows } : null;
|
||||
}
|
||||
|
||||
/** The seven-karaka table, and the eight-karaka one when the engine sent it. */
|
||||
export function mapKarakaTables(packet: Record<string, unknown>) {
|
||||
const seven = mapKarakaTable(packet.chara_karakas, "7");
|
||||
const eight = mapKarakaTable(packet.chara_karakas_8, "8") ?? mapKarakaTable(packet.chara_karakas, "8");
|
||||
return { "7": seven, "8": eight };
|
||||
}
|
||||
|
||||
const ROLES = ["benefic", "malefic", "neutral", "pending"] as const;
|
||||
const IDENTITIES = ["yogakaraka", "badhaka", "kendradhipati"] as const;
|
||||
|
||||
export function mapFunctional(block: unknown): ChartViewFunctional | null {
|
||||
const packet = computed(block);
|
||||
const profile = text(packet?.profile);
|
||||
const translation = text(packet?.translation);
|
||||
if (!packet || !profile || !translation || !Array.isArray(packet.rows)) return null;
|
||||
const rows = packet.rows.flatMap((raw): ChartViewFunctional["rows"] => {
|
||||
const row = record(raw);
|
||||
const name = text(row?.planet);
|
||||
const role = ROLES.find((item) => item === row?.functional);
|
||||
const natural = row?.natural === "benefic" || row?.natural === "malefic" ? row.natural : null;
|
||||
if (!row || !name || !role || !natural) return [];
|
||||
const basisText = text(row.role_basis) ?? "";
|
||||
return [{
|
||||
name,
|
||||
natural,
|
||||
naturalCondition: row.natural_condition === "lunar_phase_not_evaluated" ? "phase"
|
||||
: row.natural_condition === "association_not_evaluated" ? "association" : null,
|
||||
houses: Array.isArray(row.owned_houses) ? row.owned_houses.flatMap((item) => (houseOf(item) ? [houseOf(item)!] : [])) : [],
|
||||
role,
|
||||
basis: role === "pending" ? "node" : basisText.includes("exception") ? "exception" : "general",
|
||||
identities: (Array.isArray(row.identities) ? row.identities : []).flatMap((item) => {
|
||||
const identity = record(item);
|
||||
const key = IDENTITIES.find((value) => value === identity?.key);
|
||||
return key ? [{ key, house: houseOf(identity?.house) }] : [];
|
||||
}),
|
||||
conflict: row.natural_functional_conflict === true,
|
||||
}];
|
||||
});
|
||||
return rows.length > 0 ? { profile, translation, rows } : null;
|
||||
}
|
||||
|
||||
function point(
|
||||
row: Record<string, unknown>,
|
||||
key: string,
|
||||
set: NonNullable<ChartViewPoint["set"]>,
|
||||
term: PointTerm,
|
||||
extra: Partial<ChartViewPoint> = {},
|
||||
): ChartViewPoint | null {
|
||||
const sign = text(row.sign);
|
||||
const house = houseOf(row.house);
|
||||
if (!sign || !isSignName(sign) || house === null) return null;
|
||||
const pada = finite(row.nakshatra_pada);
|
||||
const lord = text(row.nakshatra_lord);
|
||||
return {
|
||||
key,
|
||||
group: "special",
|
||||
set,
|
||||
label: term.label,
|
||||
short: term.short,
|
||||
english: term.english,
|
||||
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: lord ? planetZh(lord) : null,
|
||||
sourceHouse: null,
|
||||
sourceLord: null,
|
||||
note: term.note,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const NODE_MODE_ZH: Readonly<Record<string, string>> = { mean: "平均交点", true: "真交点" };
|
||||
|
||||
function sensitiveReference(key: string, reference: Record<string, unknown> | null): string | null {
|
||||
if (!reference) return null;
|
||||
if (key === "Bhrigu_Bindu") {
|
||||
const arc = text(reference.arc) === "forward_rahu_to_moon" ? "罗睺→月亮顺行弧中点" : text(reference.arc);
|
||||
const mode = NODE_MODE_ZH[text(reference.node_mode) ?? ""] ?? "交点口径未注明";
|
||||
return arc ? `${arc} · ${mode}` : null;
|
||||
}
|
||||
const base = text(reference.base_sign);
|
||||
const count = finite(reference.count);
|
||||
const division = text(reference.division);
|
||||
const from = text(reference.from) === "Moon" ? "月亮" : text(reference.from) === "Lagna" ? "上升" : null;
|
||||
return from && base && isSignName(base) && count && division ? `${from} ${division} ${signZh(base)}起第${count}` : null;
|
||||
}
|
||||
|
||||
export function mapSensitive(block: unknown): ChartViewPoint[] | null {
|
||||
const packet = computed(block);
|
||||
if (!packet || !Array.isArray(packet.rows)) return null;
|
||||
const rows = packet.rows.flatMap((raw) => {
|
||||
const row = record(raw);
|
||||
const key = text(row?.key);
|
||||
const term = key ? SENSITIVE_TERMS[key] : undefined;
|
||||
if (!row || !key || !term) return [];
|
||||
const lord = text(row.lord);
|
||||
const signOnly = row.precision === "sign";
|
||||
const mapped = point(signOnly ? { sign: row.sign, house: row.house } : row, key, "sensitive", term, {
|
||||
signLord: lord ? planetZh(lord) : null,
|
||||
reference: sensitiveReference(key, record(row.reference)),
|
||||
});
|
||||
return mapped ? [mapped] : [];
|
||||
});
|
||||
return rows.length > 0 ? rows : null;
|
||||
}
|
||||
|
||||
export function mapSahams(block: unknown): { rows: ChartViewPoint[]; daytime: boolean | null } | null {
|
||||
const packet = computed(block);
|
||||
if (!packet || !Array.isArray(packet.rows)) return null;
|
||||
const rows = packet.rows.flatMap((raw) => {
|
||||
const row = record(raw);
|
||||
const key = text(row?.key);
|
||||
if (!row || !key) return [];
|
||||
const ruled = row.rule_backed === true;
|
||||
const operands = Array.isArray(row.formula_operands) ? row.formula_operands.filter((item): item is string => typeof item === "string") : [];
|
||||
const variant = row.formula_variant === "day" || row.formula_variant === "night" ? row.formula_variant : null;
|
||||
const name = text(row.name) ?? key.replace(/_/g, " ");
|
||||
const gloss = SAHAM_TERMS[key];
|
||||
const term: PointTerm = {
|
||||
label: gloss?.gloss ?? name,
|
||||
short: gloss?.short ?? name.slice(0, 2),
|
||||
english: name,
|
||||
note: ruled ? `${gloss?.gloss ?? name}相关的 Saham 点` : SAHAM_LEGACY,
|
||||
};
|
||||
const mapped = point(row, key, "saham", term, {
|
||||
primary: row.primary === true,
|
||||
formula: ruled && operands.length === 3 && variant ? { operands, variant, plus30: row.plus_30_applied === true } : null,
|
||||
});
|
||||
return mapped ? [mapped] : [];
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
return { rows, daytime: typeof packet.is_daytime === "boolean" ? packet.is_daytime : null };
|
||||
}
|
||||
|
||||
function strengthPair(value: unknown): { lagna: number; seventh: number } | null {
|
||||
const row = record(value);
|
||||
const lagna = finite(row?.lagna);
|
||||
const seventh = finite(row?.seventh);
|
||||
return lagna !== null && seventh !== null ? { lagna, seventh } : null;
|
||||
}
|
||||
|
||||
function planetName(value: unknown): string | null {
|
||||
const name = text(value);
|
||||
return name && (PLANET_ORDER as readonly string[]).includes(name) ? name : null;
|
||||
}
|
||||
|
||||
/** Goel table rows, and the executed points as board points in their planet's sign. */
|
||||
export function mapGoel(block: unknown): { goel: ChartViewGoel; points: ChartViewPoint[] } | null {
|
||||
const packet = computed(block);
|
||||
if (!packet || !Array.isArray(packet.rows)) return null;
|
||||
const points: ChartViewPoint[] = [];
|
||||
const rows = packet.rows.flatMap((raw): ChartViewGoel["rows"] => {
|
||||
const row = record(raw);
|
||||
const key = text(row?.key);
|
||||
const term = key ? GOEL_TERMS[key] : undefined;
|
||||
if (!row || !key || !term) return [];
|
||||
const planet = planetName(row.planet);
|
||||
const comparePlanet = planetName(record(row.compare)?.planet);
|
||||
const sign = text(row.sign);
|
||||
const house = houseOf(row.house);
|
||||
const placed = planet && sign && isSignName(sign) && house !== null;
|
||||
if (placed) {
|
||||
const mapped = point(row, key, "goel", { ...term, english: `${term.english} · ${planetZh(planet)}` });
|
||||
if (mapped) points.push(mapped);
|
||||
}
|
||||
return [{
|
||||
key,
|
||||
label: term.label,
|
||||
planet: planet ? planetZh(planet) : null,
|
||||
comparePlanet: comparePlanet ? planetZh(comparePlanet) : null,
|
||||
differs: row.differs === true,
|
||||
sign: placed ? signZh(sign) : null,
|
||||
house: placed ? house : null,
|
||||
rule: text(row.rule),
|
||||
}];
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
const strength = record(packet.lagna_seventh_strength);
|
||||
const textPair = strengthPair(strength?.include_nodes_text);
|
||||
const examplePair = strengthPair(strength?.exclude_nodes_worked_example);
|
||||
return {
|
||||
goel: { rows, strength: textPair && examplePair ? { text: textPair, example: examplePair } : null },
|
||||
points,
|
||||
};
|
||||
}
|
||||
@@ -100,16 +100,20 @@ function fakeBoard(view: ChartViewOk): ChartBoardState {
|
||||
return { model, selection: null, select: () => {}, pickPoint: () => {} } as unknown as ChartBoardState;
|
||||
}
|
||||
|
||||
test("the karaka column comes from chart_points, and a tie or a short table is left empty", () => {
|
||||
// 原值:layer.karakas 是七行数组;引擎 blocked(同度)或缺行时整列为 null
|
||||
// 新值:layer.karakaTables["7"];引擎 blocked 或缺行仍为 null,同度的角色由引擎标 tied(见
|
||||
// chart-data-engine-columns-20261102.test.tsx),不再整列留空
|
||||
// 原因:issue #163 占星师答复 2:默认七星、可切八星,同度只标「并列/角色待定」
|
||||
test("the karaka column comes from chart_points, and a blocked or short table is left empty", () => {
|
||||
const layer = mapPoints(chartPoints);
|
||||
assert.equal(layer.status, "ok");
|
||||
if (layer.status !== "ok") return;
|
||||
assert.deepEqual(layer.karakas!.map((row) => row.key), KARAKA_KEYS);
|
||||
assert.deepEqual(layer.karakas!.map((row) => row.planet), ranked);
|
||||
const tied = mapPoints({ ...insight.chart_points, chara_karakas: { status: "blocked", reason: "tied_degrees" } });
|
||||
assert.equal(tied.status === "ok" && tied.karakas, null);
|
||||
assert.deepEqual(layer.karakaTables!["7"]!.rows.map((row) => row.key), KARAKA_KEYS);
|
||||
assert.deepEqual(layer.karakaTables!["7"]!.rows.map((row) => row.planet), ranked);
|
||||
const blocked = mapPoints({ ...insight.chart_points, chara_karakas: { status: "blocked", reason: "planets_missing" } });
|
||||
assert.equal(blocked.status === "ok" && blocked.karakaTables!["7"], null);
|
||||
const short = mapPoints({ ...insight.chart_points, chara_karakas: { ...charaKarakas, rows: charaKarakas.rows.slice(0, 6) } });
|
||||
assert.equal(short.status === "ok" && short.karakas, null);
|
||||
assert.equal(short.status === "ok" && short.karakaTables!["7"], null);
|
||||
});
|
||||
|
||||
// 原值:星力在每颗星下写百分比,≥100% 绿字(good)、<100% 橙字(warn),缺行不标
|
||||
@@ -387,12 +391,14 @@ test("盘面数据 has nine tabs, and 行星 lists the ascendant first with naks
|
||||
picks: DEFAULT_DATA_PICKS,
|
||||
onPicks: () => {},
|
||||
}));
|
||||
for (const label of ["盘面数据", "月宿 · 足", "宿主", "旺陷", "指标"]) assert.ok(markup.includes(label), label);
|
||||
// 原值:列名「指标」 新值:「Chara Karaka」 原因:issue #163 占星师答复 2 改列名
|
||||
for (const label of ["盘面数据", "月宿 · 足", "宿主", "旺陷", "Chara Karaka"]) assert.ok(markup.includes(label), label);
|
||||
const rows = [...markup.matchAll(/<tr[^>]*><th scope="row">([^<]+)</g)].map((match) => match[1]);
|
||||
assert.equal(rows[0], view.vedic.ascendant!.label);
|
||||
const ak = ranked[0]!;
|
||||
const akRow = view.vedic.planets.find((row) => row.name === ak)!;
|
||||
assert.match(markup, new RegExp(`${akRow.label}</th>.*?chart-graha-karaka">AK<`));
|
||||
// 原值:单元格只写「AK」 新值:「AK · 自我」,梵文全名与含义放在 title 原因:issue #163 占星师答复 2
|
||||
assert.match(markup, new RegExp(`${akRow.label}</th>.*?chart-graha-karaka" title="Atmakaraka(AK):[^"]+">AK · 自我<`));
|
||||
assert.doesNotMatch(markup, /点一行|在盘上定位/);
|
||||
// The ascendant row's dignity cell is the same 「—」 with the help bubble as the natal table (DESIGN.md, 2026-10-25).
|
||||
assert.match(markup, /chart-page-asc-status"><span data-chart-help="上升点是命盘的起点,不是行星,不判断入旺或落陷"/);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import test from "node:test";
|
||||
|
||||
import { ChartDataCard } from "../src/components/chart-board/chart-data-card.tsx";
|
||||
import type { ChartBoardState } from "../src/hooks/use-chart-board.ts";
|
||||
import { layerPoints, specialRows, stepPointInSet } from "../src/lib/chart-board-special.ts";
|
||||
import { DEFAULT_DATA_PICKS, type ChartDataTab } from "../src/lib/chart-board-focus.ts";
|
||||
import { natalBoard } from "../src/lib/chart-board-view.ts";
|
||||
import type { SpecialSet } from "../src/lib/chart-data-terms.ts";
|
||||
import { sahamFormulaText } from "../src/lib/chart-data-terms.ts";
|
||||
import { assembleChartView } from "../src/lib/chart-view-load.ts";
|
||||
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
|
||||
import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts";
|
||||
import { mapPoints } from "../src/lib/chart-view-insight.ts";
|
||||
|
||||
Object.assign(globalThis, { React });
|
||||
|
||||
const golden = JSON.parse(readFileSync(new URL("./fixtures/chart-view-golden.json", import.meta.url), "utf8")) as {
|
||||
chart: Record<string, unknown>;
|
||||
varga_full: Record<string, unknown>;
|
||||
};
|
||||
const insight = JSON.parse(readFileSync(new URL("./fixtures/chart-insight-golden.json", import.meta.url), "utf8")) as {
|
||||
chart: Record<string, unknown>;
|
||||
chart_modules_shadbala: Record<string, unknown>;
|
||||
chart_points: Record<string, unknown>;
|
||||
};
|
||||
/** Real `compute_chart_points` output for the same chart (see the fixture's `_source`). */
|
||||
const engine = JSON.parse(readFileSync(new URL("./fixtures/chart-points-engine-columns-golden.json", import.meta.url), "utf8")) as {
|
||||
chart_points: Record<string, unknown>;
|
||||
goel_split: Record<string, unknown>;
|
||||
tied_karakas: Record<string, unknown>;
|
||||
};
|
||||
const css = readFileSync(new URL("../src/components/chart-board/chart-data-engine.css", import.meta.url), "utf8");
|
||||
|
||||
const profile = {
|
||||
name: "示例",
|
||||
date: "1990-06-15",
|
||||
time: "12:00",
|
||||
placeLabel: "北京",
|
||||
latitude: 39.9042,
|
||||
longitude: 116.4074,
|
||||
timezoneOffset: 8,
|
||||
timezoneId: "Asia/Shanghai",
|
||||
ayanamsa: "raman",
|
||||
birthTimeStatus: "confirmed",
|
||||
};
|
||||
|
||||
async function okView(points: Record<string, unknown>): Promise<ChartViewOk> {
|
||||
const result = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
asOf: "2026-11-02",
|
||||
layers: CHART_VIEW_LAYERS,
|
||||
postEngine: async (path) => {
|
||||
if (path === "/api/chart") return { status: "ok", payload: { ...golden.chart, ...insight.chart, modules: { shadbala: insight.chart_modules_shadbala } } };
|
||||
if (path === "/api/varga_full") return { status: "ok", payload: golden.varga_full };
|
||||
if (path === "/api/chart_points") return { status: "ok", payload: points };
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
},
|
||||
});
|
||||
assert.equal(result.body.status, "ok");
|
||||
return result.body as ChartViewOk;
|
||||
}
|
||||
|
||||
const fullPoints = { ...insight.chart_points, ...engine.chart_points };
|
||||
|
||||
function fakeBoard(view: ChartViewOk, specialSet: SpecialSet = "lagna"): ChartBoardState {
|
||||
return {
|
||||
model: natalBoard(view),
|
||||
selection: null,
|
||||
select: () => {},
|
||||
pickPoint: () => {},
|
||||
specialSet,
|
||||
setSpecialSet: () => {},
|
||||
} as unknown as ChartBoardState;
|
||||
}
|
||||
|
||||
function render(view: ChartViewOk, tab: ChartDataTab, specialSet?: SpecialSet): string {
|
||||
return renderToStaticMarkup(React.createElement(ChartDataCard, {
|
||||
view,
|
||||
board: fakeBoard(view, specialSet),
|
||||
type: "D1",
|
||||
tab,
|
||||
onTab: () => {},
|
||||
picks: DEFAULT_DATA_PICKS,
|
||||
onPicks: () => {},
|
||||
}));
|
||||
}
|
||||
|
||||
function rowOf(markup: string, label: string): string {
|
||||
const match = new RegExp(`<tr[^>]*><th scope="row">${label}</th>(.*?)</tr>`).exec(markup);
|
||||
assert.ok(match, label);
|
||||
return match[1]!;
|
||||
}
|
||||
|
||||
test("chart_points maps both karaka schemes, the functional table and three new 特殊点 groups", () => {
|
||||
const layer = mapPoints(fullPoints);
|
||||
assert.equal(layer.status, "ok");
|
||||
if (layer.status !== "ok") return;
|
||||
assert.deepEqual(layer.karakaTables!["7"]!.rows.map((row) => row.key), ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"]);
|
||||
assert.deepEqual(layer.karakaTables!["8"]!.rows.map((row) => row.key), ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"]);
|
||||
assert.ok(layer.karakaTables!["8"]!.rows.some((row) => row.planet === "Rahu"));
|
||||
assert.ok(!layer.karakaTables!["8"]!.rows.some((row) => row.planet === "Ketu"));
|
||||
assert.equal(layer.functional!.profile, "bphs_ch34_general_with_sign_exceptions_v2");
|
||||
assert.equal(layer.functional!.rows.length, 9);
|
||||
assert.deepEqual(layer.functional!.rows.filter((row) => row.role === "pending").map((row) => row.name), ["Rahu", "Ketu"]);
|
||||
const sets = (set: string) => layer.special!.filter((point) => (point.set ?? "lagna") === set);
|
||||
assert.equal(sets("lagna").length, 10, "eight special lagnas plus Yogi and Avayogi, unchanged");
|
||||
assert.deepEqual(sets("sensitive").map((point) => point.key), ["Bhrigu_Bindu", "Navamsa_64", "Drekkana_22"]);
|
||||
assert.equal(sets("saham").filter((point) => point.primary).length, 9);
|
||||
assert.equal(sets("saham").length, (engine.chart_points.sahams as { rows: unknown[] }).rows.length, "every computed Saham is kept");
|
||||
assert.equal(sets("goel").length, 3);
|
||||
});
|
||||
|
||||
test("行星 shows Chara Karaka as 「AK · 自我」 and functional roles beside the natural nature", async () => {
|
||||
const view = await okView(fullPoints);
|
||||
const markup = render(view, "planets");
|
||||
for (const head of ["Chara Karaka", "自然属性", "管辖宫位", "功能角色", "补充身份"]) assert.ok(markup.includes(`>${head}</th>`), head);
|
||||
assert.match(markup, /title="bphs_ch34_general_with_sign_exceptions_v2 · BPHS ch\.34, public English translation https:\/\/jyotishvidya\.com\/ch34\.htm[^"]*">功能角色</);
|
||||
assert.ok(markup.includes("七星制") && markup.includes("八星制"));
|
||||
// Leo: Mars owns 4 and 9, the strict Yogakaraka, and is also the 9th-lord Badhaka.
|
||||
const mars = rowOf(markup, "火星");
|
||||
assert.match(mars, /MK · 母亲/);
|
||||
assert.match(mars, /凶星/);
|
||||
assert.match(mars, /偏吉/支持/);
|
||||
assert.match(mars, />Yogakaraka</);
|
||||
assert.match(mars, />Badhaka · 9宫主</);
|
||||
assert.match(rowOf(markup, "金星"), /Kendradhipati 待综合/);
|
||||
assert.match(rowOf(markup, "罗睺"), /chart-functional-role is-pending[^>]*>待综合</);
|
||||
assert.match(rowOf(markup, "计都"), />待综合</);
|
||||
assert.match(rowOf(markup, "月亮"), /吉星 · 随月相/);
|
||||
// 旺陷 keeps its own 「中性」 dignity label; the functional column never says it.
|
||||
assert.doesNotMatch(markup, /chart-functional-role[^>]*>[^<]*中性/);
|
||||
});
|
||||
|
||||
test("a karaka tie marks only the tied bodies and keeps every other role", async () => {
|
||||
const view = await okView({ ...fullPoints, chara_karakas: engine.tied_karakas });
|
||||
const markup = render(view, "planets");
|
||||
assert.equal(markup.match(/并列/角色待定/g)?.length, 2);
|
||||
assert.match(rowOf(markup, "太阳"), /title="与月亮同度,GK \/ DK 待定"/);
|
||||
assert.match(rowOf(markup, "金星"), /AK · 自我/);
|
||||
assert.match(rowOf(markup, "土星"), /BK · 手足|MK · 母亲|PK · 子女|AmK · 职事/);
|
||||
});
|
||||
|
||||
test("特殊点 groups: sign-only points show no degree, Saham shows its formula, Goel shows the worked example", async () => {
|
||||
const view = await okView({ ...fullPoints, goel_points: engine.goel_split });
|
||||
for (const label of ["特殊上升", "敏感点", "Saham", "Goel 特殊点"]) assert.ok(render(view, "special").includes(label), label);
|
||||
const sensitive = render(view, "special", "sensitive");
|
||||
assert.match(sensitive, /罗睺→月亮顺行弧中点 · 平均交点/);
|
||||
assert.match(sensitive, /月亮 D9 双鱼起第64/);
|
||||
assert.match(sensitive, /上升 D3 白羊起第22/);
|
||||
const n64 = /第64 Navamsa<small>64th Navamsa<\/small><\/button><\/th><td class="chart-graha-num">([^<]*)</.exec(sensitive);
|
||||
assert.equal(n64?.[1], "双子", "the sign alone, no invented degree");
|
||||
assert.doesNotMatch(sensitive, /灾难/);
|
||||
const saham = render(view, "special", "saham");
|
||||
assert.equal(saham.match(/<tr[^>]*aria-selected/g)?.length, 9);
|
||||
assert.match(saham, /首批九项/);
|
||||
assert.match(saham, /全部 <small>56<\/small>/);
|
||||
assert.ok(saham.includes(sahamFormulaText({ operands: ["Moon", "Sun", "Ascendant"], variant: "day", plus30: false })));
|
||||
assert.ok(saham.includes("日 · 11宫 − 11宫主 + 升 +30°"));
|
||||
const goel = render(view, "special", "goel");
|
||||
assert.match(goel, /所取星 · 书面规则含交点/);
|
||||
assert.match(goel, /<td class="chart-graha-karaka"[^>]*>水星<\/td>.*?<td class="is-differs"[^>]*>木星<\/td>/);
|
||||
assert.equal(goel.match(/>相同</g)?.length, 2);
|
||||
});
|
||||
|
||||
test("the board's 特殊点 layer draws the open group, and stepping stays inside it", async () => {
|
||||
const view = await okView(fullPoints);
|
||||
assert.equal(layerPoints(view, "special", "lagna").length, 10);
|
||||
assert.equal(layerPoints(view, "special", "sensitive").length, 3);
|
||||
assert.equal(layerPoints(view, "special", "saham").length, 9, "the first nine on the board");
|
||||
assert.equal(layerPoints(view, "special", "goel").length, 3);
|
||||
assert.equal(layerPoints(view, "upagraha", "saham").length, 11);
|
||||
const sensitive = specialRows(view, "sensitive")!;
|
||||
const last = sensitive.at(-1)!;
|
||||
assert.equal(stepPointInSet(view, "special", last.key, 1)?.key, sensitive[0]!.key);
|
||||
});
|
||||
|
||||
test("the new styles use theme tokens only", () => {
|
||||
assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b/i);
|
||||
assert.match(css, /color-mix\(/);
|
||||
assert.match(css, /\.is-pending/);
|
||||
});
|
||||
|
||||
test("the chart view asks chart_points for both karaka schemes in one call", async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
asOf: "2026-11-02",
|
||||
layers: ["points"],
|
||||
postEngine: async (path, body) => {
|
||||
if (path === "/api/chart") return { status: "ok", payload: { ...golden.chart, ...insight.chart } };
|
||||
if (path === "/api/chart_points") { bodies.push(body); return { status: "ok", payload: fullPoints }; }
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
},
|
||||
});
|
||||
assert.equal(bodies.length, 1);
|
||||
assert.equal(bodies[0]!.karaka_scheme, "both");
|
||||
assert.equal(bodies[0]!.node_mode, "mean", "Bhrigu Bindu follows the chart's own node mode");
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,3 +30,10 @@ and period activation are independent layers. Neutral does not mean harmless;
|
||||
functional benefic does not guarantee a favorable event. No death or outcome
|
||||
prediction is inferred from this classification. The Yoga engine's natural
|
||||
predicates and active Yoga rule library are not replaced by ownership roles.
|
||||
|
||||
Chart page layout (issue #163): `scripts/chart_view_points/functional_roles.py` lays this
|
||||
profile out one row per body for the 盘面数据 table. It adds no rule: the
|
||||
functional class is the profile's; Rahu/Ketu are `pending` (association not
|
||||
evaluated); Yogakaraka and Badhaka (lord of `yoga_engine.badhaka_house`) are
|
||||
separate labels; a natural benefic owning 4/7/10 and none of 1/5/9 is marked
|
||||
Kendradhipati with `conditions_not_evaluated`.
|
||||
|
||||
@@ -9,8 +9,21 @@ page does not have to run the full reading:
|
||||
- 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``
|
||||
- Chara karakas, seven-planet scheme: ``jaimini.calc_chara_karaka_7``; two of the
|
||||
seven on the same in-sign degree block the table instead of picking an order
|
||||
- Chara karakas: ``chart_view_points.karaka_rank.rank_chara_karakas`` (the ranking Goel
|
||||
uses too), seven-karaka by default; ``karaka_scheme`` ``"8"`` gives the
|
||||
eight-karaka table (Rahu reversed, Ketu out) and ``"both"`` adds it as
|
||||
``chara_karakas_8``. Bodies equal at full precision mark only the roles they
|
||||
span as tied; the other roles keep their planet (issue #163)
|
||||
- functional roles: ``chart_view_points.functional_roles.functional_role_table`` over
|
||||
``functional_benefics`` profile ``bphs_ch34_general_with_sign_exceptions_v2``
|
||||
- sensitive points: Bhrigu Bindu from ``jyotish_engine._calc_sensitive_points``
|
||||
(the chart's own Rahu, so it follows ``node_mode``), 64th Navamsa from the
|
||||
Moon and 22nd Drekkana from the Lagna (``varga``); the last two are a sign
|
||||
and its lord only
|
||||
- Sahams: ``tajika.calc_all_sahams`` (PVR §28.8.1 Table 74; day or night from
|
||||
the birth time and place, ``saham_daynight`` Hindu rising)
|
||||
- Goel special points: ``goel_jaimini_strength.build_goel_chart_evidence``
|
||||
under the printed-text node policy, with the worked-example policy beside it
|
||||
|
||||
Each block fails on its own (``status: blocked`` + reason); one failure never
|
||||
fails the response.
|
||||
@@ -83,12 +96,23 @@ SPECIAL_ORDER = (
|
||||
"Bhava_Lagna", "Hora_Lagna", "Ghati_Lagna", "ViGhati_Lagna",
|
||||
"Pranapada_Lagna", "Sree_Lagna", "Indu_Lagna", "Varnada_Lagna",
|
||||
)
|
||||
KARAKA_SHORT = {
|
||||
"Atmakaraka": "AK", "Amatyakaraka": "AmK", "Bhratrikaraka": "BK", "Matrikaraka": "MK",
|
||||
"Putrakaraka": "PK", "Gnatikaraka": "GK", "Darakaraka": "DK",
|
||||
}
|
||||
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||||
NINE = SEVEN + ("Rahu", "Ketu")
|
||||
ARUDHA_ORDER = ("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL")
|
||||
# Interface order for the Saham group: these nine first, the rest under 「全部」.
|
||||
SAHAM_PRIMARY = (
|
||||
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Mitra_Saham", "Vivaha_Saham",
|
||||
"Artha_Saham", "Labha_Saham", "Karyasiddhi_Saham", "Paradesa_Saham",
|
||||
)
|
||||
GOEL_POINTS = ("Brahma", "Maheshwara", "Rudra")
|
||||
GOEL_POLICIES = ("include_nodes_text", "exclude_nodes_worked_example")
|
||||
|
||||
|
||||
def _karaka_scheme(body: dict[str, Any]) -> str:
|
||||
value = str(body.get("karaka_scheme") or "7").strip()
|
||||
if value not in ("7", "8", "both"):
|
||||
raise ChartPointsInputError("karaka_scheme must be 7, 8 or both")
|
||||
return value
|
||||
|
||||
|
||||
def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -107,6 +131,9 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
|
||||
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")
|
||||
node_mode = str(body.get("node_mode") or "unspecified")
|
||||
karaka_scheme = _karaka_scheme(body)
|
||||
longitudes = {name: value for name in NINE if (value := _longitude(planets.get(name))) is not None}
|
||||
birth = datetime(year, month, day, hour, minute, second)
|
||||
asc_sign = int(asc_lon // 30) % 12
|
||||
|
||||
@@ -170,24 +197,154 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
|
||||
})
|
||||
return {"status": "computed", "rows": rows}
|
||||
|
||||
def karakas() -> dict[str, Any]:
|
||||
from jaimini import calc_chara_karaka_7
|
||||
def karakas(scheme: int) -> dict[str, Any]:
|
||||
from chart_view_points.karaka_rank import rank_chara_karakas
|
||||
|
||||
degrees = {name: lon % 30 for name in SEVEN if (lon := _longitude(planets.get(name))) is not None}
|
||||
if len(degrees) < len(SEVEN):
|
||||
ranking = rank_chara_karakas({name: lon % 30 for name, lon in longitudes.items()}, scheme)
|
||||
if ranking["status"] != "computed":
|
||||
return {"status": "blocked", "reason": ranking["reason"], "scheme": f"chara_{scheme}"}
|
||||
rows = [
|
||||
{
|
||||
"key": row["key"], "name": row["name"], "rank": row["rank"], "planet": row["planet"],
|
||||
"tied": row["tied"], "candidates": row["candidates"],
|
||||
"degree_in_sign": round(row["degree_in_sign"], 6), "sort_degree": round(row["sort_degree"], 6),
|
||||
}
|
||||
for row in ranking["rows"]
|
||||
]
|
||||
return {
|
||||
"status": "computed",
|
||||
"scheme": f"chara_{scheme}",
|
||||
"rows": rows,
|
||||
"ties": ranking["ties"],
|
||||
"sort_basis": ranking["sort_basis"],
|
||||
"tie_tolerance": ranking["tie_tolerance"],
|
||||
}
|
||||
|
||||
def functional() -> dict[str, Any]:
|
||||
from chart_view_points.functional_roles import functional_role_table
|
||||
|
||||
return functional_role_table(SIGNS[asc_sign])
|
||||
|
||||
def sensitive() -> dict[str, Any]:
|
||||
from jyotish_engine import _calc_sensitive_points
|
||||
from varga import calc_22nd_drekkana, calc_64th_navamsa
|
||||
|
||||
rows = []
|
||||
bhrigu = _calc_sensitive_points(planets).get("bhrigu_bindu")
|
||||
if isinstance(bhrigu, dict) and isinstance(bhrigu.get("longitude"), (int, float)):
|
||||
rows.append(_point(
|
||||
"Bhrigu_Bindu", float(bhrigu["longitude"]), asc_sign,
|
||||
precision="longitude", lord=bhrigu.get("lord"),
|
||||
reference={"from": "Rahu", "to": "Moon", "arc": bhrigu.get("arc_mode"), "node_mode": node_mode},
|
||||
))
|
||||
moon = longitudes.get("Moon")
|
||||
if moon is not None:
|
||||
n64 = calc_64th_navamsa(moon)
|
||||
sign = SIGNS.index(n64["sign"])
|
||||
rows.append({
|
||||
"key": "Navamsa_64", "sign": n64["sign"], "house": (sign - asc_sign) % 12 + 1,
|
||||
"lord": n64["lord"], "precision": "sign",
|
||||
"reference": {"from": "Moon", "base_sign": n64["base_navamsa_sign"], "division": "D9", "count": 64},
|
||||
})
|
||||
d22 = calc_22nd_drekkana(asc_lon)
|
||||
sign = SIGNS.index(d22["sign"])
|
||||
rows.append({
|
||||
"key": "Drekkana_22", "sign": d22["sign"], "house": (sign - asc_sign) % 12 + 1,
|
||||
"lord": d22["lord"], "precision": "sign",
|
||||
"reference": {"from": "Lagna", "base_sign": d22["base_drekkana_sign"], "division": "D3", "count": 22},
|
||||
})
|
||||
return {"status": "computed", "rows": rows, "node_mode": node_mode}
|
||||
|
||||
def sahams() -> dict[str, Any]:
|
||||
from tajika import calc_all_sahams
|
||||
|
||||
if len(longitudes) < len(NINE):
|
||||
return {"status": "blocked", "reason": "planets_missing"}
|
||||
# calc_chara_karaka_7 reports four decimals; equal there is a tie it would order arbitrarily.
|
||||
if len({round(value, 4) for value in degrees.values()}) < len(SEVEN):
|
||||
return {"status": "blocked", "reason": "tied_degrees"}
|
||||
table = calc_chara_karaka_7(degrees).get("karaka_table") or {}
|
||||
rows = sorted(
|
||||
(
|
||||
{"key": KARAKA_SHORT[name], "planet": row["planet"], "rank": row["rank"], "degree_in_sign": row["degree_in_sign"]}
|
||||
for name, row in table.items() if name in KARAKA_SHORT
|
||||
),
|
||||
key=lambda row: row["rank"],
|
||||
)
|
||||
return {"status": "computed", "scheme": "chara_7", "rows": rows}
|
||||
packet = calc_all_sahams(dict(longitudes), asc_lon, birth, "natal", lat=lat, lon=lon, tz=tz)
|
||||
if packet.get("status") == "blocked":
|
||||
return {"status": "blocked", "reason": str(packet.get("reason"))}
|
||||
rows = []
|
||||
for value in packet.values():
|
||||
if not isinstance(value, dict) or not isinstance(value.get("longitude"), (int, float)):
|
||||
continue
|
||||
rule = value.get("formula_rule")
|
||||
key = rule or str(value.get("name") or "").replace(" ", "_")
|
||||
extra: dict[str, Any] = {
|
||||
"name": value.get("name"),
|
||||
"rule_backed": bool(rule),
|
||||
"primary": key in SAHAM_PRIMARY,
|
||||
}
|
||||
if rule:
|
||||
extra.update({
|
||||
"formula_operands": value.get("formula_operands"),
|
||||
"formula_variant": value.get("formula_variant"),
|
||||
"plus_30_applied": value.get("zodiacal_order_exception_applied"),
|
||||
})
|
||||
rows.append(_point(key, float(value["longitude"]), asc_sign, **extra))
|
||||
order = {key: index for index, key in enumerate(SAHAM_PRIMARY)}
|
||||
rows.sort(key=lambda row: (order.get(row["key"], len(order)), not row["rule_backed"]))
|
||||
daynight = packet.get("daynight_evidence") or {}
|
||||
return {
|
||||
"status": "computed",
|
||||
"rows": rows,
|
||||
"is_daytime": daynight.get("is_daytime"),
|
||||
"daynight_method": daynight.get("method"),
|
||||
"source": "PVR Narasimha Rao, Vedic Astrology: An Integrated Approach, §28.8.1 Table 74",
|
||||
}
|
||||
|
||||
def goel() -> dict[str, Any]:
|
||||
try:
|
||||
from scripts.goel_jaimini_strength import PROFILE, SOURCE, build_goel_chart_evidence
|
||||
except ImportError:
|
||||
from goel_jaimini_strength import PROFILE, SOURCE, build_goel_chart_evidence
|
||||
|
||||
if len(longitudes) < len(NINE):
|
||||
return {"status": "blocked", "reason": "planets_missing"}
|
||||
runs = {
|
||||
policy: build_goel_chart_evidence(dict(longitudes), asc_lon, node_occupancy_policy=policy)
|
||||
for policy in GOEL_POLICIES
|
||||
}
|
||||
main, compare = (runs[policy] for policy in GOEL_POLICIES)
|
||||
if main["strength"].get("status") != "executed":
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": str(main["strength"].get("reason")),
|
||||
"karaka_ties": main["strength"].get("karaka_ties"),
|
||||
"profile": PROFILE,
|
||||
}
|
||||
rows = []
|
||||
for name in GOEL_POINTS:
|
||||
point, other = main["special_points"].get(name) or {}, compare["special_points"].get(name) or {}
|
||||
planet = point.get("value") if point.get("status") == "executed" else None
|
||||
row: dict[str, Any] = {
|
||||
"key": f"Goel_{name}",
|
||||
"point": name,
|
||||
"status": point.get("status"),
|
||||
"planet": planet,
|
||||
"rule": point.get("rule"),
|
||||
"reason": point.get("reason"),
|
||||
"candidates": point.get("candidates"),
|
||||
"compare": {
|
||||
"status": other.get("status"),
|
||||
"planet": other.get("value") if other.get("status") == "executed" else None,
|
||||
"reason": other.get("reason"),
|
||||
},
|
||||
}
|
||||
row["differs"] = (row["status"], planet) != (row["compare"]["status"], row["compare"]["planet"])
|
||||
if planet in longitudes:
|
||||
sign = int(longitudes[planet] // 30) % 12
|
||||
row.update({"sign": SIGNS[sign], "house": (sign - asc_sign) % 12 + 1})
|
||||
rows.append(row)
|
||||
return {
|
||||
"status": "computed",
|
||||
"rows": rows,
|
||||
"policy": GOEL_POLICIES[0],
|
||||
"compare_policy": GOEL_POLICIES[1],
|
||||
"lagna_seventh_strength": {policy: runs[policy].get("lagna_seventh_strength") for policy in GOEL_POLICIES},
|
||||
"karaka_scheme": "chara_7",
|
||||
"profile": PROFILE,
|
||||
"source": SOURCE,
|
||||
}
|
||||
|
||||
def friendship() -> dict[str, Any]:
|
||||
from jyotish_engine import _build_planetary_friendship_snapshot
|
||||
@@ -203,5 +360,10 @@ def compute_chart_points(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"yogi_points": _guard(yogi),
|
||||
"arudha_padas": _guard(arudha),
|
||||
"friendship": _guard(friendship),
|
||||
"chara_karakas": _guard(karakas),
|
||||
"chara_karakas": _guard(lambda: karakas(8 if karaka_scheme == "8" else 7)),
|
||||
**({"chara_karakas_8": _guard(lambda: karakas(8))} if karaka_scheme == "both" else {}),
|
||||
"functional_roles": _guard(functional),
|
||||
"sensitive_points": _guard(sensitive),
|
||||
"sahams": _guard(sahams),
|
||||
"goel_points": _guard(goel),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Per-planet functional role table for the chart page (issue #163).
|
||||
|
||||
Reads ``functional_benefics.derive_functional_benefic_malefic`` (profile
|
||||
``bphs_ch34_general_with_sign_exceptions_v2``) and lays it out one row per
|
||||
body, with the natural nature and two independent identities beside it.
|
||||
Nothing here reclassifies a planet:
|
||||
|
||||
- ``functional`` is the profile's class for the seven. ``neutral`` in that
|
||||
profile means mixed / association-dependent; associations are not evaluated
|
||||
there, so it is never a "cancelled out" verdict.
|
||||
- Rahu and Ketu are not in the seven-lord table. Their role depends on
|
||||
placement, dispositor and associations, which nothing here evaluates, so
|
||||
``functional`` is ``pending``.
|
||||
- ``yogakaraka`` is the profile's strict field: distinct lordships, one of
|
||||
4/7/10 and one of 5/9.
|
||||
- ``badhaka`` is the lord of ``yoga_engine.badhaka_house`` (movable 11,
|
||||
fixed 9, dual 7). It is a separate label; it never changes ``functional``.
|
||||
- ``kendradhipati`` marks a natural benefic that owns 4, 7 or 10 and none of
|
||||
1, 5, 9. The dosha's conditions (associations, placement, maraka role) are
|
||||
not evaluated anywhere in the engine, so the label is always
|
||||
``conditions_not_evaluated``.
|
||||
|
||||
``functional_benefics.py`` is part of the rectification production identity,
|
||||
so this layout lives in the chart page package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from functional_benefics import PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic, normalize_sign
|
||||
from yoga_engine import BENEFICS, MALEFICS, badhaka_house
|
||||
except ImportError: # package import (scripts.chart_view_points.functional_roles)
|
||||
from scripts.functional_benefics import PROFILE, SIGN_LORDS, SIGNS, SOURCE, derive_functional_benefic_malefic, normalize_sign
|
||||
from scripts.yoga_engine import BENEFICS, MALEFICS, badhaka_house
|
||||
|
||||
BODIES = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
||||
NODES = ("Rahu", "Ketu")
|
||||
# The engine's natural lists treat Moon and Mercury as benefics without
|
||||
# checking the Moon's phase or Mercury's company; say so instead of judging it.
|
||||
NATURAL_CONDITION = {"Moon": "lunar_phase_not_evaluated", "Mercury": "association_not_evaluated"}
|
||||
TRANSLATION = "BPHS ch.34, public English translation https://jyotishvidya.com/ch34.htm (inspected 2026-10-02)"
|
||||
|
||||
|
||||
def functional_role_table(ascendant: Any) -> dict[str, Any]:
|
||||
base = derive_functional_benefic_malefic(ascendant)
|
||||
asc_sign = normalize_sign(ascendant)
|
||||
if base.get("status") != "used" or asc_sign is None:
|
||||
return {"status": "blocked", "reason": "unknown_ascendant_sign", "profile": PROFILE, "source": SOURCE}
|
||||
benefics = set(base["functional_benefics"])
|
||||
malefics = set(base["functional_malefics"])
|
||||
yogakarakas = set(base["yogakarakas"])
|
||||
owned = base["owned_houses"]
|
||||
b_house = badhaka_house(asc_sign)
|
||||
b_lord = SIGN_LORDS[SIGNS[(SIGNS.index(asc_sign) + b_house - 1) % 12]] if b_house else None
|
||||
|
||||
rows = []
|
||||
for planet in BODIES:
|
||||
natural = "benefic" if planet in BENEFICS else "malefic" if planet in MALEFICS else None
|
||||
houses = list(owned.get(planet, []))
|
||||
identities: list[dict[str, Any]] = []
|
||||
if planet in NODES:
|
||||
functional = "pending"
|
||||
basis = "node_placement_dispositor_association_not_evaluated"
|
||||
else:
|
||||
functional = "benefic" if planet in benefics else "malefic" if planet in malefics else "neutral"
|
||||
basis = base["role_basis"].get(planet)
|
||||
if planet in yogakarakas:
|
||||
identities.append({"key": "yogakaraka"})
|
||||
if planet == b_lord:
|
||||
identities.append({"key": "badhaka", "house": b_house})
|
||||
if (natural == "benefic" and set(houses) & {4, 7, 10} and not set(houses) & {1, 5, 9}):
|
||||
identities.append({"key": "kendradhipati", "status": "conditions_not_evaluated"})
|
||||
conflict = functional in ("benefic", "malefic") and natural is not None and functional != natural
|
||||
rows.append({
|
||||
"planet": planet,
|
||||
"natural": natural,
|
||||
"natural_condition": NATURAL_CONDITION.get(planet),
|
||||
"owned_houses": houses,
|
||||
"functional": functional,
|
||||
"role_basis": basis,
|
||||
"identities": identities,
|
||||
"natural_functional_conflict": conflict,
|
||||
})
|
||||
return {
|
||||
"status": "computed",
|
||||
"ascendant": asc_sign,
|
||||
"profile": PROFILE,
|
||||
"source": SOURCE,
|
||||
"translation": TRANSLATION,
|
||||
"rule_reference": base.get("rule_reference"),
|
||||
"yogakaraka_definition": base.get("yogakaraka_definition"),
|
||||
"badhaka_rule": "movable 11 / fixed 9 / dual 7 (yoga_engine.badhaka_house)",
|
||||
"badhaka_house": b_house,
|
||||
"badhaka_lord": b_lord,
|
||||
"kendradhipati_rule": "natural benefic owning 4/7/10 and none of 1/5/9; dosha conditions not evaluated",
|
||||
"neutral_meaning": "mixed / association-dependent; associations not evaluated",
|
||||
"rows": rows,
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Chara karaka ranking with explicit ties (issue #163).
|
||||
|
||||
One ranking for the chart page's karaka column and Goel's full Jaimini
|
||||
strength. Bodies are ordered by their unrounded in-sign degree, highest first.
|
||||
In the eight-karaka scheme Rahu is included, Ketu is not, and Rahu ranks by
|
||||
``30 - degree in sign``, the same reversal as ``jaimini.calc_chara_karaka_8``.
|
||||
|
||||
Two bodies whose sort degrees are still equal at full precision (within
|
||||
``TIE_TOLERANCE``) form a tie. The roles they span are reported as contested
|
||||
with every candidate; nothing is ordered by input or array order. Roles outside
|
||||
a tie keep their planet.
|
||||
|
||||
``jaimini.calc_chara_karaka_7`` / ``calc_chara_karaka_8`` are part of the
|
||||
rectification production identity and are not changed here; on untied input
|
||||
this ranking gives the same order (tests/test_chara_karaka_rank.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
try:
|
||||
from jaimini import KARAKA_7, KARAKA_8
|
||||
except ImportError: # package import (scripts.chart_view_points.karaka_rank)
|
||||
from scripts.jaimini import KARAKA_7, KARAKA_8
|
||||
|
||||
SEVEN = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn")
|
||||
EIGHT = SEVEN + ("Rahu",)
|
||||
TIE_TOLERANCE = 1e-10
|
||||
KARAKA_SHORT = {
|
||||
"Atmakaraka": "AK", "Amatyakaraka": "AmK", "Bhratrikaraka": "BK", "Matrikaraka": "MK",
|
||||
"Pitrukaraka": "PiK", "Putrakaraka": "PK", "Gnatikaraka": "GK", "Darakaraka": "DK",
|
||||
}
|
||||
SCHEMES = {7: (SEVEN, KARAKA_7), 8: (EIGHT, KARAKA_8)}
|
||||
|
||||
|
||||
def sort_degree(planet: str, degree_in_sign: float, scheme: int) -> float:
|
||||
"""The value a body is ranked by: in-sign degree, Rahu reversed in the 8 scheme."""
|
||||
raw = float(degree_in_sign) % 30.0
|
||||
if scheme == 8 and planet == "Rahu":
|
||||
return (30.0 - raw) % 30.0
|
||||
return raw
|
||||
|
||||
|
||||
def rank_chara_karakas(degrees: Mapping[str, float], scheme: int = 7) -> dict[str, Any]:
|
||||
"""Rank the scheme's bodies; ``degrees`` holds in-sign degrees or longitudes.
|
||||
|
||||
Returns ``status: blocked`` when a body is missing. Otherwise ``rows`` has
|
||||
one entry per role in rank order. A tied role has ``planet: None``,
|
||||
``tied: True`` and the tied bodies in ``candidates``; ``ties`` lists each
|
||||
tie once with the roles it spans.
|
||||
"""
|
||||
if scheme not in SCHEMES:
|
||||
raise ValueError("chara karaka scheme must be 7 or 8")
|
||||
bodies, roles = SCHEMES[scheme]
|
||||
missing = [planet for planet in bodies if not isinstance(degrees.get(planet), (int, float)) or isinstance(degrees.get(planet), bool)]
|
||||
if missing:
|
||||
return {"status": "blocked", "scheme": scheme, "reason": "planets_missing", "missing": missing}
|
||||
raw = {planet: float(degrees[planet]) % 30.0 for planet in bodies}
|
||||
keyed = {planet: sort_degree(planet, raw[planet], scheme) for planet in bodies}
|
||||
ordered = sorted(bodies, key=lambda planet: keyed[planet], reverse=True)
|
||||
|
||||
# Group runs of equal sort degrees. A run of one is an ordinary rank.
|
||||
groups: list[list[str]] = []
|
||||
for planet in ordered:
|
||||
if groups and abs(keyed[groups[-1][-1]] - keyed[planet]) <= TIE_TOLERANCE:
|
||||
groups[-1].append(planet)
|
||||
else:
|
||||
groups.append([planet])
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
ties: list[dict[str, Any]] = []
|
||||
by_planet: dict[str, dict[str, Any]] = {}
|
||||
rank = 1
|
||||
for group in groups:
|
||||
span = list(range(rank, rank + len(group)))
|
||||
tied = len(group) > 1
|
||||
candidates = [planet for planet in bodies if planet in group] # fixed body order, not input order
|
||||
span_keys = [KARAKA_SHORT[roles[r]] for r in span]
|
||||
if tied:
|
||||
ties.append({"planets": candidates, "roles": span_keys, "sort_degree": keyed[group[0]]})
|
||||
for offset, r in enumerate(span):
|
||||
name = roles[r]
|
||||
planet = None if tied else group[0]
|
||||
rows.append({
|
||||
"rank": r,
|
||||
"key": KARAKA_SHORT[name],
|
||||
"name": name,
|
||||
"planet": planet,
|
||||
"tied": tied,
|
||||
"candidates": candidates if tied else [group[0]],
|
||||
"degree_in_sign": raw[group[offset]],
|
||||
"sort_degree": keyed[group[offset]],
|
||||
"rahu_reverse_applied": scheme == 8 and not tied and group[0] == "Rahu",
|
||||
})
|
||||
for planet in group:
|
||||
by_planet[planet] = {
|
||||
"role": None if tied else span_keys[0],
|
||||
"tied": tied,
|
||||
"contested_roles": span_keys if tied else [],
|
||||
"tied_with": [p for p in candidates if p != planet],
|
||||
}
|
||||
rank += len(group)
|
||||
return {
|
||||
"status": "computed",
|
||||
"scheme": scheme,
|
||||
"rows": rows,
|
||||
"ties": ties,
|
||||
"planets": by_planet,
|
||||
"order": ordered if not ties else None,
|
||||
"tie_tolerance": TIE_TOLERANCE,
|
||||
"sort_basis": "unrounded_degree_in_sign" if scheme == 7 else "unrounded_degree_in_sign_rahu_30_minus",
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import math
|
||||
from scripts.trikona_raman import CLASSICAL, DIGNITY_PROFILE, _dignity, _validate_positions
|
||||
from scripts.narayana_dasha import _rasi_aspects
|
||||
from scripts.sthira_brahma_goel_short_method import SIGNS, SIGN_LORDS
|
||||
from scripts.chart_view_points.karaka_rank import rank_chara_karakas
|
||||
|
||||
PROFILE = 'goel_full_jaimini_strength_ch1_v1'
|
||||
SOURCE = 'V. P. Goel, Predicting through Jaimini Astrology'
|
||||
@@ -21,10 +22,12 @@ def calculate_goel_strength(planet_longitudes, *, node_occupancy_policy='include
|
||||
_validate_positions(planet_longitudes)
|
||||
signs = {p: int(v // 30) for p, v in planet_longitudes.items()}
|
||||
degrees = {p: planet_longitudes[p] % 30 for p in CLASSICAL}
|
||||
ranked = sorted(CLASSICAL, key=degrees.get, reverse=True)
|
||||
if any(math.isclose(degrees[a], degrees[b], rel_tol=0, abs_tol=1e-10)
|
||||
for i, a in enumerate(ranked) for b in ranked[i + 1:]):
|
||||
return dict(status='blocked', reason='equal_degree_seven_karaka_rank_requires_finer_positions', profile_id=PROFILE)
|
||||
# Shared with the chart page's karaka column: explicit ties, never input order.
|
||||
ranking = rank_chara_karakas(degrees, 7)
|
||||
if ranking['ties']:
|
||||
return dict(status='blocked', reason='equal_degree_seven_karaka_rank_requires_finer_positions',
|
||||
profile_id=PROFILE, karaka_ties=ranking['ties'])
|
||||
ranked = ranking['order']
|
||||
ak = ranked[0]
|
||||
karakas = dict(zip(ranked, KARAKA_POINTS))
|
||||
|
||||
@@ -72,12 +75,18 @@ def calculate_goel_strength(planet_longitudes, *, node_occupancy_policy='include
|
||||
claim_boundary='Named Goel full method with declared dignity ranges; not PL9 or all-lineage parity.')
|
||||
|
||||
|
||||
def build_goel_chart_evidence(planet_longitudes, ascendant_longitude):
|
||||
def build_goel_chart_evidence(planet_longitudes, ascendant_longitude, *, node_occupancy_policy='include_nodes_text'):
|
||||
"""Strength and Brahma / Maheshwara / Rudra under one node occupancy policy.
|
||||
|
||||
The default is the printed p14 text (nodes count in Sthana). Passing
|
||||
``exclude_nodes_worked_example`` selects the special points from the p15
|
||||
worked-example totals instead, so a caller can show where they differ.
|
||||
"""
|
||||
if (type(ascendant_longitude) not in (int, float)
|
||||
or not math.isfinite(ascendant_longitude)
|
||||
or not 0 <= ascendant_longitude < 360):
|
||||
raise ValueError('ascendant longitude must be a finite number in [0,360)')
|
||||
strength = calculate_goel_strength(planet_longitudes)
|
||||
strength = calculate_goel_strength(planet_longitudes, node_occupancy_policy=node_occupancy_policy)
|
||||
result = dict(strength=strength, special_points={})
|
||||
if strength['status'] != 'executed':
|
||||
result['special_points'] = {
|
||||
@@ -85,11 +94,12 @@ def build_goel_chart_evidence(planet_longitudes, ascendant_longitude):
|
||||
for name in ('Brahma', 'Maheshwara', 'Rudra')
|
||||
}
|
||||
return result
|
||||
strength['worked_example_node_exclusion'] = calculate_goel_strength(
|
||||
planet_longitudes, node_occupancy_policy='exclude_nodes_worked_example')
|
||||
strength['source_discrepancy'] = (
|
||||
'Printed p14 includes nodes in Sthana; printed p15 worked totals omit them. '
|
||||
'Both numeric alternatives are preserved; the explicit text is the active profile.')
|
||||
if node_occupancy_policy == 'include_nodes_text':
|
||||
strength['worked_example_node_exclusion'] = calculate_goel_strength(
|
||||
planet_longitudes, node_occupancy_policy='exclude_nodes_worked_example')
|
||||
strength['source_discrepancy'] = (
|
||||
'Printed p14 includes nodes in Sthana; printed p15 worked totals omit them. '
|
||||
'Both numeric alternatives are preserved; the explicit text is the active profile.')
|
||||
lagna = int(float(ascendant_longitude) % 360 // 30)
|
||||
seventh = (lagna + 6) % 12
|
||||
planets = strength['planets']
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Shared Chara karaka ranking (issue #163): explicit ties, same order as jaimini on untied input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (ROOT, ROOT / "scripts"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from chart_view_points.karaka_rank import rank_chara_karakas # noqa: E402
|
||||
from jaimini import calc_chara_karaka_7, calc_chara_karaka_8 # noqa: E402
|
||||
|
||||
BODIES = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu")
|
||||
|
||||
|
||||
def _random_degrees(seed: int) -> dict[str, float]:
|
||||
rng = random.Random(seed)
|
||||
return {name: rng.uniform(0, 30) for name in BODIES}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", range(20))
|
||||
def test_untied_order_matches_jaimini_seven_and_eight(seed):
|
||||
degrees = _random_degrees(seed)
|
||||
seven = rank_chara_karakas(degrees, 7)
|
||||
table7 = calc_chara_karaka_7(degrees)["karaka_table"]
|
||||
assert [row["planet"] for row in seven["rows"]] == [table7[row["name"]]["planet"] for row in seven["rows"]]
|
||||
eight = rank_chara_karakas(degrees, 8)
|
||||
table8 = calc_chara_karaka_8(degrees)["karaka_table_8"]
|
||||
assert [row["planet"] for row in eight["rows"]] == [table8[row["name"]]["planet"] for row in eight["rows"]]
|
||||
assert "Ketu" not in eight["planets"]
|
||||
|
||||
|
||||
def test_a_three_way_tie_spans_three_roles_and_names_no_planet():
|
||||
degrees = {"Sun": 20.0, "Moon": 10.0, "Mars": 10.0, "Mercury": 10.0, "Jupiter": 25.0, "Venus": 5.0, "Saturn": 1.0}
|
||||
result = rank_chara_karakas(degrees, 7)
|
||||
assert result["order"] is None
|
||||
assert result["ties"] == [{"planets": ["Moon", "Mars", "Mercury"], "roles": ["BK", "MK", "PK"], "sort_degree": 10.0}]
|
||||
by_key = {row["key"]: row for row in result["rows"]}
|
||||
assert [by_key[key]["planet"] for key in ("AK", "AmK", "GK", "DK")] == ["Jupiter", "Sun", "Venus", "Saturn"]
|
||||
assert all(by_key[key]["planet"] is None for key in ("BK", "MK", "PK"))
|
||||
assert result["planets"]["Mars"] == {"role": None, "tied": True, "contested_roles": ["BK", "MK", "PK"], "tied_with": ["Moon", "Mercury"]}
|
||||
|
||||
|
||||
def test_tie_candidates_do_not_depend_on_input_order():
|
||||
forward = {"Sun": 10.0, "Moon": 10.0, "Mars": 3.0, "Mercury": 4.0, "Jupiter": 5.0, "Venus": 6.0, "Saturn": 7.0}
|
||||
backward = dict(reversed(list(forward.items())))
|
||||
assert rank_chara_karakas(forward, 7)["rows"] == rank_chara_karakas(backward, 7)["rows"]
|
||||
|
||||
|
||||
def test_rahu_ties_on_its_reversed_degree_in_the_eight_scheme():
|
||||
degrees = {"Sun": 20.0, "Moon": 12.0, "Mars": 3.0, "Mercury": 4.0, "Jupiter": 5.0, "Venus": 6.0, "Saturn": 7.0, "Rahu": 18.0}
|
||||
result = rank_chara_karakas(degrees, 8)
|
||||
assert result["ties"][0]["planets"] == ["Moon", "Rahu"]
|
||||
assert rank_chara_karakas(degrees, 7)["ties"] == []
|
||||
|
||||
|
||||
def test_missing_body_blocks():
|
||||
assert rank_chara_karakas({"Sun": 1.0}, 7)["status"] == "blocked"
|
||||
with pytest.raises(ValueError):
|
||||
rank_chara_karakas({}, 9)
|
||||
@@ -90,11 +90,135 @@ def test_chara_karakas_rank_the_seven_by_in_sign_degree():
|
||||
assert [row["planet"] for row in rows] == expected
|
||||
|
||||
|
||||
def test_chara_karakas_block_on_a_tie():
|
||||
def test_chara_karakas_mark_only_the_tied_roles():
|
||||
planets = {name: dict(row) for name, row in BODY["planets"].items()}
|
||||
planets["Moon"]["lon"] = planets["Sun"]["lon"] % 30 + 30 * int(planets["Moon"]["lon"] // 30)
|
||||
result = compute_chart_points({**BODY, "planets": planets})
|
||||
assert result["chara_karakas"] == {"status": "blocked", "reason": "tied_degrees"}
|
||||
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
|
||||
# 原值: {"status": "blocked", "reason": "tied_degrees"},整列留空
|
||||
# 新值: computed;只有 Sun / Moon 跨的两个角色标 tied,其余五个角色照常
|
||||
# 原因: issue #163 占星师答复 2:同度不整列清空,只标「并列/角色待定」
|
||||
assert result["status"] == "computed"
|
||||
tied = [row for row in result["rows"] if row["tied"]]
|
||||
assert [row["key"] for row in tied] == ["GK", "DK"]
|
||||
assert all(row["planet"] is None and row["candidates"] == ["Sun", "Moon"] for row in tied)
|
||||
assert result["ties"] == [{"planets": ["Sun", "Moon"], "roles": ["GK", "DK"], "sort_degree": pytest.approx(planets["Sun"]["lon"] % 30)}]
|
||||
untied = [row for row in result["rows"] if not row["tied"]]
|
||||
assert [row["planet"] for row in untied] == ["Venus", "Jupiter", "Mars", "Mercury", "Saturn"]
|
||||
|
||||
|
||||
def test_chara_karakas_sort_by_unrounded_degree():
|
||||
"""Equal at four decimals is not a tie; the old column blanked on rounded values."""
|
||||
planets = {name: dict(row) for name, row in BODY["planets"].items()}
|
||||
sign = 30 * int(planets["Moon"]["lon"] // 30)
|
||||
planets["Moon"]["lon"] = sign + planets["Sun"]["lon"] % 30 + 0.00001
|
||||
result = compute_chart_points({**BODY, "planets": planets})["chara_karakas"]
|
||||
assert result["ties"] == []
|
||||
assert [row["planet"] for row in result["rows"]][-2:] == ["Moon", "Sun"]
|
||||
|
||||
|
||||
def test_karaka_scheme_parameter():
|
||||
seven = compute_chart_points(BODY)
|
||||
assert seven["chara_karakas"]["scheme"] == "chara_7"
|
||||
assert "chara_karakas_8" not in seven
|
||||
eight = compute_chart_points({**BODY, "karaka_scheme": "8"})["chara_karakas"]
|
||||
assert eight["scheme"] == "chara_8"
|
||||
assert [row["key"] for row in eight["rows"]] == ["AK", "AmK", "BK", "MK", "PiK", "PK", "GK", "DK"]
|
||||
assert "Ketu" not in [row["planet"] for row in eight["rows"]]
|
||||
rahu = next(row for row in eight["rows"] if row["planet"] == "Rahu")
|
||||
assert rahu["sort_degree"] == pytest.approx(30 - BODY["planets"]["Rahu"]["lon"] % 30, abs=1e-6)
|
||||
both = compute_chart_points({**BODY, "karaka_scheme": "both"})
|
||||
assert both["chara_karakas"]["scheme"] == "chara_7"
|
||||
assert both["chara_karakas_8"]["rows"] == eight["rows"]
|
||||
with pytest.raises(ChartPointsInputError):
|
||||
compute_chart_points({**BODY, "karaka_scheme": "9"})
|
||||
|
||||
|
||||
def test_functional_roles_keep_profile_identities_and_pending_nodes():
|
||||
result = compute_chart_points(BODY)["functional_roles"]
|
||||
assert result["status"] == "computed"
|
||||
assert result["ascendant"] == "Leo"
|
||||
assert result["profile"] == "bphs_ch34_general_with_sign_exceptions_v2"
|
||||
assert "jyotishvidya.com/ch34.htm" in result["translation"]
|
||||
rows = {row["planet"]: row for row in result["rows"]}
|
||||
assert list(rows) == ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"]
|
||||
# Leo: Mars owns 4 and 9 (strict yogakaraka); fixed sign, so the Badhaka is the 9th lord, also Mars.
|
||||
assert rows["Mars"]["functional"] == "benefic"
|
||||
assert {"key": "yogakaraka"} in rows["Mars"]["identities"]
|
||||
assert {"key": "badhaka", "house": 9} in rows["Mars"]["identities"]
|
||||
# Venus (3, 10): natural benefic owning a kendra and no trine; conditions are not evaluated.
|
||||
assert {"key": "kendradhipati", "status": "conditions_not_evaluated"} in rows["Venus"]["identities"]
|
||||
assert rows["Venus"]["functional"] == "malefic" and rows["Venus"]["natural_functional_conflict"] is True
|
||||
for node in ("Rahu", "Ketu"):
|
||||
assert rows[node]["functional"] == "pending"
|
||||
assert rows[node]["identities"] == []
|
||||
assert rows["Moon"]["natural_condition"] == "lunar_phase_not_evaluated"
|
||||
|
||||
|
||||
def test_sensitive_points_follow_the_chart_nodes_and_keep_sign_only_points():
|
||||
result = compute_chart_points({**BODY, "node_mode": "mean"})["sensitive_points"]
|
||||
assert result["status"] == "computed"
|
||||
rows = {row["key"]: row for row in result["rows"]}
|
||||
assert list(rows) == ["Bhrigu_Bindu", "Navamsa_64", "Drekkana_22"]
|
||||
rahu = BODY["planets"]["Rahu"]["lon"]
|
||||
moon = BODY["planets"]["Moon"]["lon"]
|
||||
assert rows["Bhrigu_Bindu"]["longitude"] == pytest.approx((rahu + ((moon - rahu) % 360) / 2) % 360, abs=1e-3)
|
||||
assert rows["Bhrigu_Bindu"]["reference"] == {"from": "Rahu", "to": "Moon", "arc": "forward_rahu_to_moon", "node_mode": "mean"}
|
||||
for key, anchor in (("Navamsa_64", "Moon"), ("Drekkana_22", "Lagna")):
|
||||
assert rows[key]["precision"] == "sign"
|
||||
assert "longitude" not in rows[key] and "degree_in_sign" not in rows[key]
|
||||
assert rows[key]["reference"]["from"] == anchor
|
||||
assert rows[key]["lord"]
|
||||
|
||||
|
||||
def test_sahams_list_the_nine_first_and_keep_every_computed_row():
|
||||
result = compute_chart_points(BODY)["sahams"]
|
||||
assert result["status"] == "computed"
|
||||
assert result["is_daytime"] is True # noon in Beijing
|
||||
keys = [row["key"] for row in result["rows"]]
|
||||
assert keys[:9] == [
|
||||
"Punya_Saham", "Vidya_Saham", "Yashas_Saham", "Mitra_Saham", "Vivaha_Saham",
|
||||
"Artha_Saham", "Labha_Saham", "Karyasiddhi_Saham", "Paradesa_Saham",
|
||||
]
|
||||
assert [row["primary"] for row in result["rows"]].count(True) == 9
|
||||
assert sum(row["rule_backed"] for row in result["rows"]) == 36
|
||||
assert len(keys) == len(set(keys))
|
||||
rows = {row["key"]: row for row in result["rows"]}
|
||||
punya = rows["Punya_Saham"]
|
||||
assert punya["formula_operands"] == ["Moon", "Sun", "Ascendant"] and punya["formula_variant"] == "day"
|
||||
sun, moon, asc = (float(BODY["planets"]["Sun"]["lon"]), float(BODY["planets"]["Moon"]["lon"]), float(BODY["ascendant"]["lon"]))
|
||||
expected = (moon - sun + asc) % 360 + (0 if (asc - sun) % 360 <= (moon - sun) % 360 else 30)
|
||||
assert punya["longitude"] == pytest.approx(expected % 360, abs=1e-3)
|
||||
assert rows["Karya_Saham"]["rule_backed"] is False
|
||||
|
||||
|
||||
GOEL_SPLIT = {
|
||||
"Sun": 102.29, "Moon": 123.1, "Mars": 90.57, "Mercury": 18.98, "Jupiter": 104.09,
|
||||
"Venus": 127.86, "Saturn": 177.74, "Rahu": 120.14, "Ketu": 300.14,
|
||||
}
|
||||
|
||||
|
||||
def test_goel_points_default_to_the_printed_text_and_show_the_worked_example():
|
||||
body = {**BODY, "planets": {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}, "ascendant": {"lon": 135.0}}
|
||||
result = compute_chart_points(body)["goel_points"]
|
||||
assert result["status"] == "computed"
|
||||
assert (result["policy"], result["compare_policy"]) == ("include_nodes_text", "exclude_nodes_worked_example")
|
||||
assert result["karaka_scheme"] == "chara_7"
|
||||
rows = {row["point"]: row for row in result["rows"]}
|
||||
brahma = rows["Brahma"]
|
||||
assert (brahma["planet"], brahma["compare"]["planet"], brahma["differs"]) == ("Mercury", "Jupiter", True)
|
||||
assert (brahma["sign"], brahma["house"]) == ("Aries", 9)
|
||||
strength = result["lagna_seventh_strength"]
|
||||
assert (strength["include_nodes_text"]["lagna"], strength["include_nodes_text"]["seventh"]) == (337.5, 352.5)
|
||||
assert (strength["exclude_nodes_worked_example"]["lagna"], strength["exclude_nodes_worked_example"]["seventh"]) == (322.5, 292.5)
|
||||
assert rows["Maheshwara"]["differs"] is False and rows["Rudra"]["differs"] is False
|
||||
|
||||
|
||||
def test_goel_points_block_on_a_karaka_tie_and_say_which():
|
||||
planets = {name: {"lon": lon} for name, lon in GOEL_SPLIT.items()}
|
||||
planets["Moon"] = {"lon": 120 + GOEL_SPLIT["Sun"] % 30}
|
||||
result = compute_chart_points({**BODY, "planets": planets, "ascendant": {"lon": 135.0}})["goel_points"]
|
||||
assert result["status"] == "blocked"
|
||||
assert result["karaka_ties"][0]["planets"] == ["Sun", "Moon"]
|
||||
|
||||
|
||||
def test_one_failing_block_does_not_fail_the_response(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Functional role table (issue #163): a layout of the v2 profile, never a reclassification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
for path in (ROOT, ROOT / "scripts"):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from functional_benefics import SIGNS, derive_functional_benefic_malefic # noqa: E402
|
||||
from chart_view_points.functional_roles import functional_role_table # noqa: E402
|
||||
from yoga_engine import badhaka_house # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sign", SIGNS)
|
||||
def test_every_lagna_mirrors_the_profile(sign):
|
||||
base = derive_functional_benefic_malefic(sign)
|
||||
table = functional_role_table(sign)
|
||||
rows = {row["planet"]: row for row in table["rows"]}
|
||||
for planet in ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"):
|
||||
expected = "benefic" if planet in base["functional_benefics"] else "malefic" if planet in base["functional_malefics"] else "neutral"
|
||||
assert rows[planet]["functional"] == expected
|
||||
assert rows[planet]["owned_houses"] == base["owned_houses"][planet]
|
||||
assert ({"key": "yogakaraka"} in rows[planet]["identities"]) == (planet in base["yogakarakas"])
|
||||
for node in ("Rahu", "Ketu"):
|
||||
assert rows[node]["functional"] == "pending"
|
||||
badhaka = [planet for planet, row in rows.items() if any(item["key"] == "badhaka" for item in row["identities"])]
|
||||
assert len(badhaka) == 1
|
||||
assert rows[badhaka[0]]["owned_houses"].count(badhaka_house(sign)) == 1
|
||||
|
||||
|
||||
def test_unknown_lagna_blocks():
|
||||
assert functional_role_table("Nowhere")["status"] == "blocked"
|
||||
Reference in New Issue
Block a user