Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5157f96df3 | ||
|
|
2df7645d2c |
@@ -1,5 +1,18 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-30 — 星盘页:盘旁「盘面数据」卡,表格和盘联动
|
||||
|
||||
- 盘的右边(手机上是盘的下面)新增「盘面数据」卡,九个页签:行星、虚星、特殊点、映射、八分点、星力、大运、敌友、瑜伽。原来散在「九曜」「Vimshottari 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。
|
||||
- 行星表多了上升一行、宿主、足,以及七星 Chara Karaka「指标」一列(AK~DK,由引擎排出;两颗星同度时这一列留空)。
|
||||
- 切到虚星、特殊点、映射、八分点,盘上自动换成同名图层;反过来在盘上换图层,数据卡也跟着换。
|
||||
- 切到星力、大运、敌友、瑜伽,盘上直接标出来:星力在每颗星下写达标百分比;大运标出当前大运、分运、小运的主星和大运主星管的宫;敌友按关系给其余星着色;瑜伽只亮涉及的星和宫并连线。点表里的一段大运、一格敌友或一条瑜伽,盘上跟着换。
|
||||
- 每张卡片固定高度,内容多了在卡里滚动,切页签时页面不再上下跳。表格不换行,放不下时左右滑动,鼠标可以按住拖。
|
||||
- 去掉提示类小字(例如「点一行,在盘上定位」、各卡标题旁的说明、图层下面的说明句)。
|
||||
- 图层第一项「无图层」改叫「基础星盘」;「八宫点」统一叫「八分点」。
|
||||
- 深读剩三个页签:时运流年、能量图谱、出生资料与口径。
|
||||
- 特殊点下的敏感点、Saham、Goel 特殊点三组和「功能吉凶」一列还没做:接口还没有这些数据,功能吉凶的口径待占星师确认。
|
||||
- Skill 版本不变。
|
||||
|
||||
## 2026-10-29 — D60 分盘的星座算法改对
|
||||
|
||||
- 落在金牛、巨蟹、处女、天蝎、摩羯、双鱼上的行星或上升,D60 星座改为从本星座起算(以前多移一个星座);同一张盘的 D60 星座可能与以前不同。
|
||||
|
||||
+19
-1
@@ -1049,13 +1049,31 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963
|
||||
|
||||
## 15. 只读星盘页
|
||||
|
||||
### 盘面数据卡、盘上联动与定高卡片(2026-10-30,推翻下文的部分条目)
|
||||
|
||||
产品 2026-10-30 确认原型(「星盘页 · 盘面数据卡原型」):表格搬到盘旁并与盘联动;卡片定高;不放提示类小字;表格不换行、可横向拖动;「无图层」叫「基础星盘」;全站统一叫「八分点」(Ashtakavarga,不叫「八宫点」)。下文与此冲突的条目以本节为准:九曜卡、Vimshottari 大运卡、深读「命性格局」和「排盘底册」的数据表都已拆掉,卡头右侧的提示小字、图层说明句、检视空态的三行操作说明都不再写。
|
||||
|
||||
- **布局**(≥1024px 两栏,更窄单列):第一排左「盘」卡、右「盘面数据」卡;第二排左「盘面检视」、右「宫位主题」+「命盘速览」上下两张;下面「深读」。对话右栏仍用九曜卡(`ChartGrahaCard`),不受影响。
|
||||
- **定高**:盘面数据卡在桌面绝对定位填满第一排(行高只由盘卡决定,表格行数多少都不撑高),手机 560px;检视 440px;宫位主题 + 速览合计 440px;深读 640px,页签吸顶。卡内容超出时卡内滚动。盘卡的说明条只写一行,超出截断,选中不同对象时高度不变。
|
||||
- **盘面数据**(`ChartDataCard`):一排九个页签「行星 / 虚星 / 特殊点 / 映射 / 八分点 / 星力 / 大运 / 敌友 / 瑜伽」,横滑不换行。行星表:上升一行在最前,列为星体 / 位置(逆行写「 逆」)/ 宫 / 月宿 · 足 / 宿主 / 旺陷 / 指标(七星 Chara Karaka:AK AmK BK MK PK GK DK;`/api/chart_points` 的 `chara_karakas`,同度并列时整列写「—」);盘型不是 D1 时换成该盘自己的表(原「盘型表」卡)。八分点:按宫位 / 按星座 × 原始 / 三角削减 / 两步削减的 BAV 矩阵(自能量图谱搬来)。星力:总表 / 位置力 / 时间力 / 其余四力。大运:Vimshottari / Chara / Yogini,「现在」两到三层(带进度条)+ 全部大运列表,当前那行主色。敌友:天生 / 临时 / 综合,行首和格子都能点。瑜伽:成立的列表 + 折叠的「未满足 N 条」。
|
||||
- **与盘联动**:虚星 / 特殊点 / 映射 / 八分点四个页签和盘上同名图层是一回事,切一边另一边跟着切;离开这四个页签或在盘上点「基础星盘」等其他图层,回到「行星」。星力、大运、敌友、瑜伽切过去时盘回到 D1、清掉选中,然后(`lib/chart-board-focus.ts`):
|
||||
- 星力:每颗星下面写六力达标百分比,达标绿、不足橙;罗计没有六力,变淡。
|
||||
- 大运:当前大运、分运(Vimshottari 还有小运)的主星主色并写「大运 / 分运 / 小运」,其余星变淡;大运主星管的宫浅主色底;不同宫的运主之间虚线相连。点表里任一段:只亮这颗运主(写「运主」)和它管的宫。Chara 按星座:当前(或点中的)那一段所在宫浅底。
|
||||
- 敌友:被看的那颗星(默认月亮)主色,其余七曜按关系着色并在下面写「大友 / 友 / 中 / 敌 / 大敌」(天生、临时只写友 / 中 / 敌);点格子在两颗星之间画关系色虚线。
|
||||
- 瑜伽:默认第一条;只亮涉及的星,它们所在宫浅底,星与星虚线相连,其余变淡。
|
||||
有选中对象时,盘照旧画选中对象的连线,宫底和虚线让位。
|
||||
- **表格**:单元格一行,不换行;放不下时整表横向滚动,首列固定。鼠标可以按住拖动表格和页签行(`lib/drag-scroll.ts`,拖动超过 4px 时松手不触发点选),触屏原生滑动。
|
||||
- **检视空态**:写「上升 星座 度分」和一行「月宿 · 第 N 足 · 宫主某星在第 N 宫 · 星座」,不写操作说明。
|
||||
- **深读**:三个页签「时运流年 / 能量图谱 / 出生资料与口径」。能量图谱去掉八分点矩阵(搬到盘面数据);「出生资料与口径」只留出生资料、计算口径和出生历法。各节标题旁不再写说明,九曜行运表头仍写土星七年半第几段(这是数据,不是提示)。
|
||||
- **尚未做**:原型里「特殊点」下的「敏感点 / Saham / Goel 特殊点」三组和行星表「功能吉凶」一列,还没有对应的接口数据,功能吉凶的口径也待占星师确认。
|
||||
|
||||
### 一页铺开与点选联动(2026-10-27,issue #114,推翻下文的四页签结构)
|
||||
|
||||
产品 2026-10-27 拍板(issue #114 Q1–Q9):配色仍用本站的(Q1 B),只学竞品的圆角白卡;一页铺开(Q2 A);南北印度都做、默认北印度(Q3);西洋盘保留(Q4);数字照给并在旁边写「强 / 中 / 弱」(Q5);未满足的瑜伽收起(Q6);「问 AI」只填输入框(Q7);对话一进来右栏就有盘、右栏可拖宽(Q8 A);时运流年的观测日期任意(Q9)。下文「四个 Tab」「行星表在盘下」「盘里只放彩色行星符号」三条对星盘页不再成立,报告与校正右栏仍用 `VedicChartSvg` 不变。
|
||||
|
||||
- **页头**(10-27 第五轮按原型):顶栏出生资料一行不变;正文第一行左边「名字 · 本命盘」(宋体 20px),右边「印度盘 / 西洋盘」分段 `Tabs`、「生成报告」(描边,去 `/reports`)、「带这张盘去对话」(实心,去 `/`,对话右栏默认开着本命盘)。西洋盘页原样保留。印度盘之下一行四枚圆角小签:上升 / 月亮 / 太阳 / 当前大运,点了在盘上定位,再点取消。
|
||||
- **两栏**(≥1024px,左约 53%、右约 47%;更窄时单列):左列「盘」卡 + 「盘面检视」卡 + 当前盘型自己的表(D1 没有这张,九曜卡就是本命表);右列「九曜」「宫位主题」「Vimshottari 大运」「命盘速览」四张卡。卡片白底(`--color-canvas`)、16px 圆角、一条发丝阴影,标题宋体 16px,不加红色竖条。
|
||||
- **盘卡**(10-27 第五轮按原型改回,推翻第三轮):标题「北印度盘 / 南印度盘」,右侧小字「宫位固定 / 星座固定 · 整宫制」。23 种盘型条。下面一行左边图层分段 `Tabs`「无图层 / 八宫点 / 宫力 / 叠加行运 / 虚星 / 特殊点 / 映射」(只在本命 D1,一次一个;虚星等三项是第二轮产品要的标点图层,并在同一组里),右边北印度 / 南印度。北印度盘上方浅底说明条:未选中时「本命盘 D1 / 上升 狮子 / 度数」,右端两枚小签「某星大运」「某星分运」;选中星时写「D1 · 选中 / 星名 / 星座度数 · 第 N 宫 · 实线=它管的宫,虚线=它看的宫」,选中宫写「…· 实线连到宫主所在」。南印度把同样内容写在盘中央。卡片内容网格列 `minmax(0, 1fr)`,手机上不撑宽。
|
||||
- **盘卡**(10-27 第五轮按原型改回,推翻第三轮):标题「北印度盘 / 南印度盘」,右侧小字「宫位固定 / 星座固定 · 整宫制」。23 种盘型条。下面一行左边图层分段 `Tabs`「基础星盘 / 八分点 / 宫力 / 叠加行运 / 虚星 / 特殊点 / 映射」(只在本命 D1,一次一个;虚星等三项是第二轮产品要的标点图层,并在同一组里),右边北印度 / 南印度。北印度盘上方浅底说明条:未选中时「本命盘 D1 / 上升 狮子 / 度数」,右端两枚小签「某星大运」「某星分运」;选中星时写「D1 · 选中 / 星名 / 星座度数 · 第 N 宫 · 实线=它管的宫,虚线=它看的宫」,选中宫写「…· 实线连到宫主所在」。南印度把同样内容写在盘中央。卡片内容网格列 `minmax(0, 1fr)`,手机上不撑宽。
|
||||
- **盘面**(`ChartBoardSvg`,`components/chart-board/`):星体画一字中文名(日月火水木金土罗计)+ 右上小号度数,逆行在度数后加「逆」(产品签过的原型,取代 2026-09-18 起的彩色符号;颜色仍按 `PLANET_COLOR_CLASS` 区分星体,不表示吉凶)。北印度宫内写星座名,上升宫写「升 天秤」并用 `--color-action`;南印度格内左上星座、右上宫号,上升格左上一道斜线。点星:所在宫 `--color-action-soft` 底 + 描边,它管的宫实线、它看的宫虚线(整宫计,火 4/7/8、木 5/7/9、土 3/10/7、罗计 5/7/9),连到的宫浅底,其余宫字淡到 .28。点宫:实线连宫主所在,虚线连看这一宫的星。
|
||||
- **标点图层**(10-27 第二轮,产品选「做成图层」;第三轮并进图层那一排,不再与数值图层同时开):虚星 / 特殊点 / 映射。每个点画成一枚小胶囊(`chart-board-mark`,主色描边、浅底,一到两个字:烟、古、宫升、AL、UL…),按字数量宽、间隔 3 排开(拉丁字母约 5.6、汉字约 9),一行最多三枚、多了换行;北印度两侧三角里竖排在星体旁边;选中的那枚实心。点胶囊 = 选中这个点:所在宫高亮,映射再用虚线连回它来自的宫;检视面板写名称、英文名、类别徽标、星座度数与宫、传统含义、月宿(映射没有)、所在座的主落哪一宫、映射的来历、同一座里的星,底部「问 AI:这个点」。方向键在同一组里切换。数据走懒层 `points`(`/api/chart_points`)。
|
||||
- **图层**:八宫点在每宫写「N点」,28 及以上 `--color-action`,以下土星色;宫力写引擎宫力数,高于本盘十二宫均值一成偏暖、低一成偏冷(北印度把这个数接在星座名后面);行运把观测日的行运星用土星色小字写在对应星座。图层下一行灰字说明参考线。
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ChartTypePicker } from "@/components/chart-page/chart-type-picker";
|
||||
import { ChartPageSkeleton } from "@/components/chart-page/chart-page-skeleton";
|
||||
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
|
||||
import type { BoardFocus } from "@/lib/chart-board-focus";
|
||||
import type { BoardModel, BoardSelection } from "@/lib/chart-board-model";
|
||||
import { bodyHouse, dignityStep } from "@/lib/chart-board-model";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -34,8 +35,8 @@ import { ChartBoardSvg, type ChartBoardHouseNote, type ChartBoardPointMark, type
|
||||
|
||||
/** One row: a number layer or a set of point tags, never both at once. */
|
||||
const LAYERS: ReadonlyArray<readonly [ChartBoardLayer | PointGroup, string]> = [
|
||||
["none", "无图层"],
|
||||
["sav", "八宫点"],
|
||||
["none", "基础星盘"],
|
||||
["sav", "八分点"],
|
||||
["bhava", "宫力"],
|
||||
["transit", "叠加行运"],
|
||||
["upagraha", "虚星"],
|
||||
@@ -70,7 +71,6 @@ export function boardCaption(view: ChartViewOk, source: BoardSource, model: Boar
|
||||
kicker: `${source.natal ? "D1" : source.title} · 选中`,
|
||||
title: PLANET_NAME_ZH[selection.key],
|
||||
detail: body ? `${signLabelAt(body.sign)} ${formatDegreeMinutes(body.degree)} · 第${body.house}宫` : "",
|
||||
note: "实线=它管的宫,虚线=它看的宫",
|
||||
};
|
||||
}
|
||||
if (selection?.kind === "point") {
|
||||
@@ -87,7 +87,6 @@ export function boardCaption(view: ChartViewOk, source: BoardSource, model: Boar
|
||||
kicker: `${source.natal ? "D1" : source.title} · 选中`,
|
||||
title: `第${selection.house}宫`,
|
||||
detail: `${signLabelAt(sign)} · ${HOUSE_THEME_ZH[selection.house - 1]}`,
|
||||
note: "实线连到宫主所在",
|
||||
};
|
||||
}
|
||||
const asc = model.houses[model.ascendantHouse - 1];
|
||||
@@ -140,7 +139,10 @@ export function ChartBoardCard({
|
||||
onPointSelect,
|
||||
pointsPending = false,
|
||||
pointsFailure,
|
||||
focus,
|
||||
}: {
|
||||
/** The 「盘面数据」 tab's view of the natal board. */
|
||||
focus?: BoardFocus | null;
|
||||
view: ChartViewOk;
|
||||
type: ChartTypeId;
|
||||
onType: (type: ChartTypeId) => void;
|
||||
@@ -175,12 +177,10 @@ export function ChartBoardCard({
|
||||
const caption = source && model ? boardCaption(view, source, model, selection) : null;
|
||||
const notes = source && model && natal ? houseNotes(view, model, activeLayer) : undefined;
|
||||
const overlay = activeLayer === "transit" ? transitBySign(transit) : [];
|
||||
const mean = bhavaMean(view);
|
||||
return (
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">{style === "north" ? "北印度盘" : "南印度盘"}</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">{style === "north" ? "宫位固定" : "星座固定"} · 整宫制</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="chart-board-card-body">
|
||||
<ChartTypePicker current={type} onSelect={onType} />
|
||||
@@ -215,7 +215,6 @@ export function ChartBoardCard({
|
||||
<span className="chart-board-caption-kicker">{caption.kicker}</span>
|
||||
<span className="chart-board-caption-title">{caption.title}</span>
|
||||
{caption.detail ? <span className="chart-board-caption-detail">{caption.detail}</span> : null}
|
||||
{"note" in caption && caption.note ? <span className="chart-board-caption-detail">· {caption.note}</span> : null}
|
||||
{"chips" in caption && caption.chips?.length ? (
|
||||
<span className="chart-board-caption-chips">
|
||||
{caption.chips.map((chip) => <Badge key={chip} variant="outline" className="chart-chip">{chip}</Badge>)}
|
||||
@@ -242,6 +241,7 @@ export function ChartBoardCard({
|
||||
pointMarks={pointMarks}
|
||||
onPointSelect={onPointSelect}
|
||||
dignity={natal ? dignityTones(view) : undefined}
|
||||
focus={natal ? focus : null}
|
||||
/>
|
||||
</div>
|
||||
) : !pending && failure ? (
|
||||
@@ -259,18 +259,9 @@ export function ChartBoardCard({
|
||||
{natal && activeLayer !== "none" && layerFailure && !layerPending ? (
|
||||
<p className="chart-page-note">{chartLayerFailureCopy(layerFailure)}</p>
|
||||
) : null}
|
||||
{natal && activeLayer === "sav" && !layerFailure ? (
|
||||
<p className="chart-board-layer-note">八宫点:{SAV_REFERENCE} 点及以上偏暖,以下偏冷。{SAV_REFERENCE} 是传统参考线,不是本盘均值。</p>
|
||||
) : null}
|
||||
{natal && activeLayer === "bhava" && mean !== null ? (
|
||||
<p className="chart-board-layer-note">宫力:本盘十二宫均值 {Math.round(mean)},高出一成记「{BAND_ZH.strong}」,低一成记「{BAND_ZH.weak}」。</p>
|
||||
) : null}
|
||||
{natal && pointLayer !== "none" && pointsFailure && !pointsPending ? (
|
||||
<p className="chart-page-note">{chartLayerFailureCopy(pointsFailure)}</p>
|
||||
) : null}
|
||||
{natal && activeLayer === "transit" && transit ? (
|
||||
<p className="chart-board-layer-note">蓝字是 {transit.date} 的行运位置。</p>
|
||||
) : null}
|
||||
{natal && activeLayer === "transit" && !transit && !transitPending ? (
|
||||
<p className="chart-page-note">这一天的行运暂时没算出来,稍后再试。</p>
|
||||
) : null}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type BoardModel,
|
||||
type BoardSelection,
|
||||
} from "@/lib/chart-board-model";
|
||||
import type { BoardFocus } from "@/lib/chart-board-focus";
|
||||
import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
|
||||
import { CHART_SIZE, HOUSE_POLYGONS, polygonCentroid, polygonPoints, type Point } from "@/lib/north-indian-chart-geometry";
|
||||
import { PLANET_GLYPH_ZH, PLANET_NAME_ZH, type PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
@@ -148,7 +149,10 @@ export function ChartBoardSvg({
|
||||
pointMarks = [],
|
||||
onPointSelect,
|
||||
dignity,
|
||||
focus,
|
||||
}: {
|
||||
/** The open 「盘面数据」 tab's view of the board: lit and faint bodies, marks, tinted houses, links. */
|
||||
focus?: BoardFocus | null;
|
||||
/** Natal boards only: green for exalted / own sign, red for debilitated (prototype legend). */
|
||||
dignity?: ReadonlyMap<PlanetGlyphKey, "good" | "bad">;
|
||||
model: BoardModel;
|
||||
@@ -177,6 +181,14 @@ export function ChartBoardSvg({
|
||||
const marksBySign = new Map<number, ChartBoardPointMark[]>();
|
||||
for (const mark of pointMarks) marksBySign.set(mark.sign, [...(marksBySign.get(mark.sign) ?? []), mark]);
|
||||
const selectedPoint = selection?.kind === "point" ? selection.key : null;
|
||||
const layouts = new Map(cells.map((cell) => [cell.house, bodyLayout(model.houses[cell.house - 1]!.bodies.length, cell, style)]));
|
||||
const bodyPoint = new Map<PlanetGlyphKey, Point>();
|
||||
for (const house of model.houses) {
|
||||
house.bodies.forEach((body, index) => {
|
||||
const point = layouts.get(house.house)?.points[index];
|
||||
if (point) bodyPoint.set(body.key, point);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -193,7 +205,8 @@ export function ChartBoardSvg({
|
||||
) : null}
|
||||
{cells.map((cell) => {
|
||||
const lit = relations.lit.has(cell.house);
|
||||
const state = relations.focus === cell.house ? "is-selected" : lit ? "is-related" : hasSelection ? "is-dimmed" : "";
|
||||
const tinted = !hasSelection && (focus?.houses.has(cell.house) ?? false);
|
||||
const state = relations.focus === cell.house ? "is-selected" : lit ? "is-related" : hasSelection ? "is-dimmed" : tinted ? "is-tinted" : "";
|
||||
const label = `第${cell.house}宫,${signZh(SIGN_ORDER[cell.sign] ?? "")}`;
|
||||
const select = () => onSelect(selection?.kind === "house" && selection.house === cell.house ? null : { kind: "house", house: cell.house });
|
||||
return (
|
||||
@@ -218,13 +231,24 @@ export function ChartBoardSvg({
|
||||
if (!asc || asc.shape.kind !== "rect") return null;
|
||||
return <line className="chart-board-asc-mark" x1={asc.shape.x} y1={asc.shape.y + 24} x2={asc.shape.x + 24} y2={asc.shape.y} />;
|
||||
})() : null}
|
||||
{focus && focus.links.length > 0 && !hasSelection ? (
|
||||
<g className="chart-board-focus-links" aria-hidden="true">
|
||||
{focus.links.map((link) => {
|
||||
const a = bodyPoint.get(link.from);
|
||||
const b = bodyPoint.get(link.to);
|
||||
return a && b ? (
|
||||
<line key={`${link.from}-${link.to}`} className={`chart-board-focus-link is-${link.tone}`} x1={a.x} y1={a.y - 6} x2={b.x} y2={b.y - 6} />
|
||||
) : null;
|
||||
})}
|
||||
</g>
|
||||
) : null}
|
||||
{cells.map((cell) => {
|
||||
const house = model.houses[cell.house - 1]!;
|
||||
const faded = hasSelection && !relations.lit.has(cell.house);
|
||||
const note = houseNotes?.[cell.house - 1] ?? null;
|
||||
const signName = signZh(SIGN_ORDER[cell.sign] ?? "");
|
||||
const isAsc = cell.house === model.ascendantHouse;
|
||||
const layout = bodyLayout(house.bodies.length, cell, style);
|
||||
const layout = layouts.get(cell.house)!;
|
||||
const { x: cx, y: cy } = cell.anchor;
|
||||
const labelY = !cell.triangle ? cy - 30
|
||||
: BOTTOM_HOUSES.has(cell.house) && house.bodies.length > 0 ? cy + 16
|
||||
@@ -266,14 +290,16 @@ export function ChartBoardSvg({
|
||||
{house.bodies.map((body, index) => {
|
||||
const point = layout.points[index]!;
|
||||
const selected = selectedKey === body.key;
|
||||
const dim = dimmedBodies?.has(body.key) ?? false;
|
||||
const tone = dignity?.get(body.key);
|
||||
const dim = (dimmedBodies?.has(body.key) ?? false) || (!selected && (focus?.dimmed.has(body.key) ?? false));
|
||||
const lit = focus?.lit.has(body.key) ?? false;
|
||||
const mark = focus?.marks.get(body.key);
|
||||
const tone = mark?.paint ? null : dignity?.get(body.key);
|
||||
const node = body.key === "rahu" || body.key === "ketu";
|
||||
const select = () => onSelect(selected ? null : { kind: "body", key: body.key });
|
||||
return (
|
||||
<g
|
||||
key={body.key}
|
||||
className={`chart-board-body${tone ? ` is-${tone}` : ""}${selected ? " is-selected" : ""}${dim ? " is-dim" : ""}`}
|
||||
className={`chart-board-body${tone ? ` is-${tone}` : ""}${mark?.paint ? ` is-paint-${mark.tone}` : ""}${lit ? " is-lit" : ""}${selected ? " is-selected" : ""}${dim ? " is-dim" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${PLANET_NAME_ZH[body.key]},${signName}${Math.floor(body.degree)}度${body.retrograde ? ",逆行" : ""},第${body.house}宫`}
|
||||
@@ -290,6 +316,9 @@ export function ChartBoardSvg({
|
||||
<text className="chart-board-degree" x={point.x + layout.size * 0.55} y={point.y - layout.size * 0.45}>
|
||||
{`${Math.floor(body.degree)}°${body.retrograde && !node ? " R" : ""}`}
|
||||
</text>
|
||||
{mark ? (
|
||||
<text className={`chart-board-focus-mark is-${mark.tone}`} x={point.x} y={point.y + 10} textAnchor="middle">{mark.text}</text>
|
||||
) : null}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,17 +5,69 @@
|
||||
.chart-page-panel:has(.chart-workspace) { width: min(1240px, 100%); }
|
||||
|
||||
.chart-workspace { display: grid; gap: var(--space-4); margin-top: var(--space-4); }
|
||||
/* Board beside 盘面数据, inspector beside house themes and the summary (2026-10-30).
|
||||
Every card has a fixed height and scrolls inside: a tab with more rows never moves the page.
|
||||
The data card fills the board's row; its own rows never make the row taller. */
|
||||
.chart-workspace-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.12fr) minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.chart-workspace-col { display: grid; gap: var(--space-4); min-width: 0; scroll-margin-top: var(--space-16); }
|
||||
.chart-workspace-cell { min-width: 0; scroll-margin-top: var(--space-16); }
|
||||
.chart-data-slot { position: relative; min-height: 560px; }
|
||||
.chart-data-slot > .chart-data-card { position: absolute; inset: 0; }
|
||||
.chart-side-stack { height: 440px; min-width: 0; display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-4); }
|
||||
@media (max-width: 1023px) {
|
||||
.chart-workspace-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.chart-data-slot { min-height: 0; height: 560px; }
|
||||
/* One column: each card gets its own fixed height instead of sharing a desktop row. */
|
||||
.chart-side-stack { height: auto; grid-template-rows: 300px 280px; }
|
||||
}
|
||||
|
||||
.chart-data-card,
|
||||
.chart-side-stack > .chart-board-card,
|
||||
.chart-deep { display: flex; flex-direction: column; min-height: 0; }
|
||||
.chart-data-card > [data-slot="card-content"] { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: var(--space-3); }
|
||||
.chart-data-tabs,
|
||||
.chart-data-subtabs { flex: none; max-width: 100%; overflow-x: auto; justify-content: flex-start; scrollbar-width: none; }
|
||||
.chart-data-tabs::-webkit-scrollbar,
|
||||
.chart-data-subtabs::-webkit-scrollbar { display: none; }
|
||||
.chart-data-tabs .chart-board-tab,
|
||||
.chart-data-subtabs .chart-board-tab { flex: none; }
|
||||
.chart-data-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: grid; gap: var(--space-3); align-content: start; }
|
||||
.chart-side-stack > .chart-board-card > [data-slot="card-content"],
|
||||
.chart-deep > [data-slot="card-content"] { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.chart-deep { height: 640px; }
|
||||
.chart-deep .chart-deep-tabs { position: sticky; top: 0; z-index: 2; background: var(--color-canvas); }
|
||||
|
||||
/* Wide tables: one line per cell, sideways scroll; a mouse drags them (lib/drag-scroll.ts). */
|
||||
.chart-drag,
|
||||
.chart-data-tabs,
|
||||
.chart-data-subtabs { cursor: grab; }
|
||||
.chart-drag:active { cursor: grabbing; }
|
||||
.chart-data-card .chart-graha-table th,
|
||||
.chart-data-card .chart-graha-table td,
|
||||
.chart-data-card .chart-av th,
|
||||
.chart-data-card .chart-page-planet-table th,
|
||||
.chart-data-card .chart-page-planet-table td { white-space: nowrap; }
|
||||
.chart-graha-retro { color: var(--color-warning); }
|
||||
.chart-graha-karaka { font-weight: 600; color: var(--color-ink-strong); }
|
||||
.chart-graha-table tbody tr.is-now > * { color: var(--color-action); font-weight: 600; }
|
||||
.chart-relation-table td.is-picked { background: var(--color-action-soft); box-shadow: inset 0 0 0 1px var(--color-action-strong); }
|
||||
.chart-relation-cell { color: inherit; font-size: inherit; min-height: 0; }
|
||||
.chart-type-table { display: grid; gap: var(--space-2); min-width: 0; }
|
||||
.chart-type-table-title { margin: 0; font-family: var(--font-display); font-size: 14px; font-weight: 600; }
|
||||
.chart-data-now { display: grid; gap: var(--space-1); }
|
||||
.chart-data-period {
|
||||
display: grid; grid-template-columns: 40px 64px minmax(0, 1fr); align-items: center; gap: 2px 8px;
|
||||
justify-content: stretch; padding: 6px 8px; border-radius: 10px; text-align: left; white-space: nowrap;
|
||||
}
|
||||
.chart-data-period.is-on { background: var(--color-action-soft); }
|
||||
.chart-data-period .chart-progress { grid-column: 2 / -1; margin-top: 0; }
|
||||
.chart-data-period-level { font-size: 12px; color: var(--color-ink-tertiary); }
|
||||
.chart-data-period-span { font-size: 12px; color: var(--color-ink-secondary); font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* Cards: white on the canvas, one hairline, 16px radius. */
|
||||
.chart-board-card {
|
||||
background: var(--color-canvas);
|
||||
@@ -39,10 +91,13 @@
|
||||
|
||||
.chart-board-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-2); }
|
||||
|
||||
/* One line, so the board card keeps its height whatever is selected. */
|
||||
.chart-board-caption {
|
||||
display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px;
|
||||
padding: 8px 12px; border-radius: var(--radius-sm); background: var(--color-canvas-soft); min-height: 44px;
|
||||
display: flex; flex-wrap: nowrap; align-items: baseline; gap: 10px; overflow: hidden; white-space: nowrap;
|
||||
padding: 8px 12px; border-radius: var(--radius-sm); background: var(--color-canvas-soft); height: 44px; box-sizing: border-box;
|
||||
}
|
||||
.chart-board-caption > * { flex: none; }
|
||||
.chart-board-caption > .chart-board-caption-detail { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chart-board-caption-kicker { font-size: 11px; letter-spacing: .12em; color: var(--color-action); }
|
||||
.chart-board-caption-title { font-family: var(--font-display); font-weight: 600; font-size: 18px; line-height: 1.2; }
|
||||
.chart-board-caption-detail { font-size: 12px; color: var(--color-ink-secondary); }
|
||||
@@ -95,6 +150,33 @@
|
||||
.chart-board-cell:focus-visible polygon,
|
||||
.chart-board-cell:focus-visible rect { stroke: var(--color-focus); stroke-width: 2; }
|
||||
|
||||
/* 盘面数据 focus: tinted houses, lit bodies, a word under a body, dashed links between bodies. */
|
||||
.chart-board-cell.is-tinted polygon,
|
||||
.chart-board-cell.is-tinted rect { fill: var(--color-action-soft); }
|
||||
.chart-board-body.is-lit .chart-board-glyph { fill: var(--color-action); }
|
||||
.chart-board-body.is-paint-good .chart-board-glyph { fill: var(--color-success); }
|
||||
.chart-board-body.is-paint-bad .chart-board-glyph { fill: var(--color-danger); }
|
||||
.chart-board-body.is-paint-warn .chart-board-glyph { fill: var(--color-warning); }
|
||||
.chart-board-body.is-paint-action .chart-board-glyph { fill: var(--color-action); }
|
||||
.chart-board-body.is-paint-plain .chart-board-glyph { fill: var(--color-ink-secondary); }
|
||||
.chart-board-focus-mark { font-size: 9px; font-weight: 700; pointer-events: none; }
|
||||
.chart-board-focus-mark.is-good { fill: var(--color-success); }
|
||||
.chart-board-focus-mark.is-bad { fill: var(--color-danger); }
|
||||
.chart-board-focus-mark.is-warn { fill: var(--color-warning); }
|
||||
.chart-board-focus-mark.is-action { fill: var(--color-action); }
|
||||
.chart-board-focus-mark.is-plain { fill: var(--color-ink-tertiary); }
|
||||
.chart-board-focus-links { pointer-events: none; }
|
||||
.chart-board-focus-link { stroke-width: 1.6; stroke-dasharray: 5 4; fill: none; stroke: var(--color-action); opacity: .85; }
|
||||
.chart-board-focus-link.is-good { stroke: var(--color-success); }
|
||||
.chart-board-focus-link.is-bad { stroke: var(--color-danger); }
|
||||
.chart-board-focus-link.is-plain { stroke: var(--color-ink-tertiary); }
|
||||
.chart-board-body,
|
||||
.chart-board-glyph { transition: opacity .2s, fill .2s; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chart-board-body,
|
||||
.chart-board-glyph { transition: none; }
|
||||
}
|
||||
|
||||
.chart-board-link { stroke: var(--color-action); stroke-width: 1.6; fill: none; }
|
||||
.chart-board-link.is-aspect { stroke-dasharray: 4 4; }
|
||||
.chart-board-link-dot { fill: var(--color-action); }
|
||||
@@ -330,6 +412,7 @@
|
||||
everything below it up and down (BUG-1470, issue #140). Long selections scroll
|
||||
inside the card. */
|
||||
.chart-inspector { min-width: 0; height: 440px; }
|
||||
@media (max-width: 1023px) { .chart-inspector { height: 320px; } }
|
||||
.chart-inspector > [data-slot="card-content"] { min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.chart-scroll-row { min-width: 0; }
|
||||
.chat-chart-sheet { --drawer-height: 86dvh; }
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* 「盘面数据」 (2026-10-30): one fixed-height card beside the board with the
|
||||
* chart's tables. The open tab drives the board: the point tabs and 八分点 set
|
||||
* the board layer, and 星力 / 大运 / 敌友 / 瑜伽 mark, link and dim bodies
|
||||
* (lib/chart-board-focus.ts). Tables never wrap a cell; they scroll sideways,
|
||||
* and a mouse can drag them.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
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 {
|
||||
CHART_DATA_TABS,
|
||||
dashaTrack,
|
||||
relationLabel,
|
||||
relationValue,
|
||||
type ChartDataPicks,
|
||||
type ChartDataTab,
|
||||
type DashaSystem,
|
||||
type RelationMode,
|
||||
} from "@/lib/chart-board-focus";
|
||||
import { dignityStep } from "@/lib/chart-board-model";
|
||||
import { natalBoard, pointsOf, progressOf, SAV_REFERENCE, type PointGroup } from "@/lib/chart-board-view";
|
||||
import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
|
||||
import { STRENGTH_LEVEL_ZH } from "@/lib/chart-view-insight";
|
||||
import { SIGN_ORDER, signZh, type ChartTypeId } from "@/lib/chart-view-labels";
|
||||
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 { ChartTypeTableBody } from "./chart-type-table";
|
||||
|
||||
const STHANA_PARTS = [["uccha", "高位力"], ["saptavarga", "七分盘力"], ["ojayugma", "奇偶力"], ["kendra", "角宫力"], ["drekkana", "三分力"]] as const;
|
||||
const KALA_PARTS = [["nathonnata", "昼夜力"], ["paksha", "月相力"], ["tribhaga", "三分时力"], ["ayana", "至力"], ["hora", "时主力"]] as const;
|
||||
|
||||
/** The lazy layer a tab reads. */
|
||||
function tabLayer(tab: ChartDataTab): ChartViewLayer | null {
|
||||
if (tab === "planets" || tab === "upagraha" || tab === "special" || tab === "arudha" || tab === "relation") return "points";
|
||||
if (tab === "av") return "ashtakavarga";
|
||||
return null;
|
||||
}
|
||||
|
||||
function Wide({ children }: { children: ReactNode }) {
|
||||
return <div className="chart-graha-table-wrap chart-drag" onPointerDown={dragScroll}>{children}</div>;
|
||||
}
|
||||
|
||||
function Waiting({ pending, failure }: { pending: boolean; failure?: ChartViewFailureStatus }) {
|
||||
return !pending && failure
|
||||
? <p className="chart-page-note">{chartLayerFailureCopy(failure)}</p>
|
||||
: <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 });
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<td className="chart-graha-karaka">{node ? "—" : karakaOf(row.name)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
);
|
||||
}
|
||||
|
||||
function PointTable({ view, board, group }: { view: ChartViewOk; board: ChartBoardState; group: PointGroup }) {
|
||||
const rows = pointsOf(view, group);
|
||||
if (!rows) return <p className="chart-page-note">这张表暂时没算出来。</p>;
|
||||
const picked = board.selection?.kind === "point" ? board.selection.key : null;
|
||||
const arudha = group === "arudha";
|
||||
return (
|
||||
<Wide>
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">名称</th>
|
||||
<th scope="col">{arudha ? "星座" : "黄道经度"}</th>
|
||||
<th scope="col">宫位</th>
|
||||
{arudha ? <th scope="col">来自</th> : <><th scope="col">月宿</th><th scope="col">宿主</th><th scope="col">足</th></>}
|
||||
<th scope="col">传统含义</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((point) => {
|
||||
const on = picked === point.key;
|
||||
const pick = () => board.pickPoint(on ? null : point);
|
||||
return (
|
||||
<tr key={point.key} className={on ? "is-selected" : undefined} aria-selected={on} onClick={pick}>
|
||||
<th scope="row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="chart-graha-name h-auto px-0"
|
||||
aria-pressed={on}
|
||||
onClick={(event) => { event.stopPropagation(); pick(); }}
|
||||
>
|
||||
<span className="chart-point-tag">{point.short}</span>
|
||||
{point.label}
|
||||
<small>{point.english}</small>
|
||||
</Button>
|
||||
</th>
|
||||
<td className="chart-graha-num">{point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""}</td>
|
||||
<td className="chart-graha-num">{point.house}宫</td>
|
||||
{arudha
|
||||
? <td>第 {point.sourceHouse ?? "—"} 宫 · 主 {point.sourceLord ?? "—"}</td>
|
||||
: <><td>{point.nakshatra ?? "—"}</td><td>{point.nakshatraLord ?? "—"}</td><td className="chart-graha-num">{point.pada ?? "—"}</td></>}
|
||||
<td className="chart-point-note">{point.note}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
);
|
||||
}
|
||||
|
||||
function AshtakavargaTable({ view }: { view: ChartViewOk }) {
|
||||
const [by, setBy] = useState<"house" | "sign">("house");
|
||||
const [reading, setReading] = useState<"raw" | "trikona" | "sodhita">("raw");
|
||||
const av = view.ashtakavarga?.status === "ok" ? view.ashtakavarga : null;
|
||||
if (!av) return null;
|
||||
const columns = by === "house" ? natalBoard(view).houses.map((house) => house.sign) : SIGN_ORDER.map((_, index) => index);
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={by} onValueChange={(value) => value && setBy(value as "house" | "sign")}>
|
||||
<TabsList variant="default" aria-label="排列">
|
||||
<TabsTrigger value="house" className="chart-board-tab">按宫位</TabsTrigger>
|
||||
<TabsTrigger value="sign" className="chart-board-tab">按星座</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Tabs value={reading} onValueChange={(value) => value && setReading(value as "raw" | "trikona" | "sodhita")}>
|
||||
<TabsList variant="default" aria-label="口径">
|
||||
<TabsTrigger value="raw" className="chart-board-tab">原始</TabsTrigger>
|
||||
<TabsTrigger value="trikona" className="chart-board-tab">三角削减</TabsTrigger>
|
||||
<TabsTrigger value="sodhita" className="chart-board-tab">两步削减</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="chart-av-wrap chart-drag" onPointerDown={dragScroll}>
|
||||
<Table className="chart-av">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><span className="sr-only">星体</span></th>
|
||||
{columns.map((sign, index) => (
|
||||
<th key={`${by}-${sign}`} scope="col">{by === "house" ? <>{index + 1}宫<br /></> : null}{signZh(SIGN_ORDER[sign] ?? "")}</th>
|
||||
))}
|
||||
<th scope="col">计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reading === "raw" && av.lagnaBav ? (
|
||||
<tr className="chart-av-lagna">
|
||||
<th scope="row">升</th>
|
||||
{columns.map((sign) => <td key={sign}><span>{av.lagnaBav![sign] ?? 0}</span></td>)}
|
||||
<td className="chart-av-sum">{av.lagnaBav.reduce((sum, value) => sum + value, 0)}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{av.bav.map((row) => {
|
||||
const values = row[reading];
|
||||
const key = planetGlyphKey(row.name);
|
||||
return (
|
||||
<tr key={row.name}>
|
||||
<th scope="row">{key ? PLANET_GLYPH_ZH[key] : row.label}</th>
|
||||
{columns.map((sign) => {
|
||||
const value = values[sign] ?? 0;
|
||||
return <td key={sign}><span className={value >= 6 ? "is-high" : value >= 5 ? "is-good" : value <= 2 ? "is-low" : undefined}>{value}</span></td>;
|
||||
})}
|
||||
<td className="chart-av-sum">{values.reduce((sum, value) => sum + value, 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="chart-av-total">
|
||||
<th scope="row">总</th>
|
||||
{columns.map((sign) => {
|
||||
const total = av.bav.reduce((sum, row) => sum + (row[reading][sign] ?? 0), 0);
|
||||
return <td key={sign}><span className={reading === "raw" ? (total >= SAV_REFERENCE ? "is-good" : "is-low") : undefined}>{total}</span></td>;
|
||||
})}
|
||||
<td className="chart-av-sum">{av.bav.reduce((sum, row) => sum + row[reading].reduce((a, b) => a + b, 0), 0)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StrengthTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const [part, setPart] = useState<"total" | "sthana" | "kala" | "other">("total");
|
||||
const rows = view.shadbala ?? [];
|
||||
if (rows.length === 0) return <p className="chart-page-note">这张盘没有返回六力。</p>;
|
||||
const current = board.selection?.kind === "body" ? board.selection.key : null;
|
||||
const head = part === "total" ? ["总分", "达标分", "达标鲁帕", "满足率", "差额"]
|
||||
: part === "sthana" ? [...STHANA_PARTS.map(([, label]) => label), "小计"]
|
||||
: part === "kala" ? [...KALA_PARTS.map(([, label]) => label), "小计"]
|
||||
: ["方位力", "运动力", "自然力", "视相力"];
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={part} onValueChange={(value) => value && setPart(value as typeof part)}>
|
||||
<TabsList variant="default" aria-label="六力视图" className="chart-data-subtabs" onPointerDown={dragScroll}>
|
||||
<TabsTrigger value="total" className="chart-board-tab">总表</TabsTrigger>
|
||||
<TabsTrigger value="sthana" className="chart-board-tab">位置力</TabsTrigger>
|
||||
<TabsTrigger value="kala" className="chart-board-tab">时间力</TabsTrigger>
|
||||
<TabsTrigger value="other" className="chart-board-tab">其余四力</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Wide>
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead><tr><th scope="col">行星</th>{head.map((label) => <th key={label} scope="col">{label}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const on = key !== null && current === key;
|
||||
const total = row.rupas * 60;
|
||||
const required = row.required * 60;
|
||||
let cells: ReactNode;
|
||||
if (part === "total") {
|
||||
cells = (
|
||||
<>
|
||||
<td className="chart-graha-num">{total.toFixed(1)}</td>
|
||||
<td className="chart-graha-num">{required.toFixed(0)}</td>
|
||||
<td className="chart-graha-num">{row.required.toFixed(2)}</td>
|
||||
<td className="chart-graha-num">{row.percent.toFixed(1)}%<span className={`chart-band is-${row.level}`}>{STRENGTH_LEVEL_ZH[row.level]}</span></td>
|
||||
<td className={`chart-graha-num ${total >= required ? "chart-graha-good" : "chart-graha-bad"}`}>{total >= required ? "+" : ""}{(total - required).toFixed(1)}</td>
|
||||
</>
|
||||
);
|
||||
} else if (part === "other") {
|
||||
cells = row.parts
|
||||
? ["dig", "chesta", "naisargika", "drik"].map((id) => <td key={id} className="chart-graha-num">{row.parts![id as "dig"].toFixed(1)}</td>)
|
||||
: <td colSpan={4}>—</td>;
|
||||
} else {
|
||||
const list = part === "sthana" ? STHANA_PARTS : KALA_PARTS;
|
||||
const values = part === "sthana" ? row.sthanaParts : row.kalaParts;
|
||||
cells = values
|
||||
? <>
|
||||
{list.map(([id]) => <td key={id} className="chart-graha-num">{(values as Record<string, number>)[id]!.toFixed(1)}</td>)}
|
||||
<td className="chart-graha-num"><strong>{(part === "sthana" ? row.parts?.sthana : row.parts?.kala)?.toFixed(1) ?? "—"}</strong></td>
|
||||
</>
|
||||
: <td colSpan={list.length + 1}>—</td>;
|
||||
}
|
||||
return (
|
||||
<tr key={row.name} className={on ? "is-selected" : undefined} aria-selected={on} onClick={() => key && board.select(on ? null : { kind: "body", key })}>
|
||||
<th scope="row">{row.label}</th>
|
||||
{cells}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const DASHA_SYSTEMS: ReadonlyArray<readonly [DashaSystem, string]> = [
|
||||
["vimshottari", "Vimshottari"],
|
||||
["chara", "Chara"],
|
||||
["yogini", "Yogini"],
|
||||
];
|
||||
|
||||
type DashaRow = { level: string; span: DashaSpan; sign: string | null };
|
||||
|
||||
function DashaTable({ view, picks, onPicks, pendingLayers, layerFailures, onNeedLayer }: {
|
||||
view: ChartViewOk;
|
||||
picks: ChartDataPicks;
|
||||
onPicks: (next: Partial<ChartDataPicks>) => void;
|
||||
pendingLayers?: ReadonlySet<ChartViewLayer>;
|
||||
layerFailures?: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;
|
||||
onNeedLayer?: (layer: ChartViewLayer) => void;
|
||||
}) {
|
||||
const system = picks.dashaSystem;
|
||||
const day = view.dasha.asOf;
|
||||
const track = dashaTrack(view, system);
|
||||
const lazy = system === "vimshottari" ? null : system;
|
||||
const pending = lazy ? pendingLayers?.has(lazy) ?? false : false;
|
||||
const failure = lazy ? layerFailures?.get(lazy) : undefined;
|
||||
const maha = track.periods.find((period) => period.start <= day && day < period.end) ?? null;
|
||||
const antar = maha?.antardashas.find((period) => period.start <= day && day < period.end) ?? null;
|
||||
const pratyantar = system === "vimshottari" && antar
|
||||
? vimshottariSplit(antar.lord, antar.start, antar.end).find((span) => span.start <= day && day < span.end) ?? null
|
||||
: null;
|
||||
const now: DashaRow[] = [];
|
||||
if (maha) now.push({ level: "大运", span: maha, sign: maha.sign });
|
||||
if (antar) now.push({ level: "分运", span: antar, sign: system === "chara" ? antar.lord : null });
|
||||
if (pratyantar) now.push({ level: "小运", span: pratyantar, sign: null });
|
||||
const picked = picks.dashaPick;
|
||||
const pick = (row: DashaRow) => onPicks({
|
||||
dashaPick: picked?.start === row.span.start && picked.level === row.level
|
||||
? null
|
||||
: { start: row.span.start, level: row.level, lord: system === "chara" ? null : row.span.lord, sign: system === "chara" ? row.sign : null },
|
||||
});
|
||||
const isPicked = (row: DashaRow) => picked?.start === row.span.start && picked.level === row.level;
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs
|
||||
value={system}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
onPicks({ dashaSystem: value as DashaSystem, dashaPick: null });
|
||||
if (value !== "vimshottari") onNeedLayer?.(value as ChartViewLayer);
|
||||
}}
|
||||
>
|
||||
<TabsList variant="default" aria-label="运法">
|
||||
{DASHA_SYSTEMS.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
{track.periods.length === 0 || pending || failure ? <Waiting pending={pending || track.periods.length === 0} failure={failure} /> : (
|
||||
<>
|
||||
<div className="chart-data-now">
|
||||
{now.map((row) => (
|
||||
<Button
|
||||
key={row.level}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-pressed={isPicked(row)}
|
||||
className={isPicked(row) ? "chart-data-period is-on h-auto" : "chart-data-period h-auto"}
|
||||
onClick={() => pick(row)}
|
||||
>
|
||||
<span className="chart-data-period-level">{row.level}</span>
|
||||
<strong>{row.span.label}</strong>
|
||||
<span className="chart-data-period-span">{row.span.start} – {row.span.end}</span>
|
||||
<span className="chart-progress"><i style={{ width: `${(progressOf(row.span.start, row.span.end, day) * 100).toFixed(1)}%` }} /></span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Wide>
|
||||
<Table className="chart-graha-table">
|
||||
<thead><tr><th scope="col">大运</th><th scope="col">起</th><th scope="col">止</th></tr></thead>
|
||||
<tbody>
|
||||
{track.periods.map((period) => {
|
||||
const row: DashaRow = { level: "大运", span: period, sign: period.sign };
|
||||
const on = isPicked(row);
|
||||
const isNow = maha?.start === period.start;
|
||||
return (
|
||||
<tr
|
||||
key={period.start}
|
||||
className={on ? "is-selected" : isNow ? "is-now" : undefined}
|
||||
aria-selected={on}
|
||||
onClick={() => pick(row)}
|
||||
>
|
||||
<th scope="row">{period.label}</th>
|
||||
<td className="chart-graha-num">{period.start}</td>
|
||||
<td className="chart-graha-num">{period.end}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const RELATION_MODES: ReadonlyArray<readonly [RelationMode, string]> = [
|
||||
["natural", "天生"],
|
||||
["temporary", "临时"],
|
||||
["compound", "综合"],
|
||||
];
|
||||
|
||||
function RelationTable({ view, picks, onPicks }: {
|
||||
view: ChartViewOk;
|
||||
picks: ChartDataPicks;
|
||||
onPicks: (next: Partial<ChartDataPicks>) => void;
|
||||
}) {
|
||||
const friendship = view.points?.status === "ok" ? view.points.friendship : null;
|
||||
if (!friendship) return <p className="chart-page-note">这张表暂时没算出来。</p>;
|
||||
const keys = friendship.rows.map((row) => planetGlyphKey(row.name)).filter((key): key is PlanetGlyphKey => key !== null);
|
||||
const mode = picks.relationMode;
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={mode} onValueChange={(value) => value && onPicks({ relationMode: value as RelationMode })}>
|
||||
<TabsList variant="default" aria-label="敌友口径">
|
||||
{RELATION_MODES.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Wide>
|
||||
<Table className="chart-graha-table is-sticky chart-relation-table">
|
||||
<thead>
|
||||
<tr><th scope="col"><span className="sr-only">星体</span></th>{keys.map((key) => <th key={key} scope="col">{PLANET_GLYPH_ZH[key]}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((from) => {
|
||||
const rowOn = picks.relationOf === from;
|
||||
return (
|
||||
<tr key={from} className={rowOn ? "is-selected" : undefined} aria-selected={rowOn}>
|
||||
<th scope="row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="chart-graha-name h-auto px-0"
|
||||
aria-pressed={rowOn}
|
||||
onClick={() => onPicks({ relationOf: from, relationTo: null })}
|
||||
>
|
||||
{PLANET_NAME_ZH[from]}
|
||||
</Button>
|
||||
</th>
|
||||
{keys.map((to) => {
|
||||
if (to === from) return <td key={to} className="chart-relation-self">—</td>;
|
||||
const value = relationValue(view, from, to, mode);
|
||||
if (value === null) return <td key={to}>—</td>;
|
||||
const on = rowOn && picks.relationTo === to;
|
||||
return (
|
||||
<td key={to} className={`${value > 0 ? "chart-graha-good" : value < 0 ? "chart-graha-bad" : ""}${on ? " is-picked" : ""}`.trim() || undefined}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="chart-relation-cell h-auto px-0"
|
||||
aria-pressed={on}
|
||||
aria-label={`${PLANET_NAME_ZH[from]}看${PLANET_NAME_ZH[to]}:${relationLabel(value, mode)}`}
|
||||
onClick={() => onPicks({ relationOf: from, relationTo: on ? null : to })}
|
||||
>
|
||||
{relationLabel(value, mode)}
|
||||
</Button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Wide>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function YogaList({ view, picks, onPicks }: {
|
||||
view: ChartViewOk;
|
||||
picks: ChartDataPicks;
|
||||
onPicks: (next: Partial<ChartDataPicks>) => void;
|
||||
}) {
|
||||
const yogas = view.yogas;
|
||||
if (!yogas) return <p className="chart-page-note">这张盘没有返回格局。</p>;
|
||||
return (
|
||||
<>
|
||||
{yogas.formed.length > 0 ? (
|
||||
<div className="chart-yoga-list">
|
||||
{yogas.formed.map((yoga, index) => {
|
||||
const on = picks.yogaPick === index;
|
||||
return (
|
||||
<Button
|
||||
key={yoga.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-pressed={on}
|
||||
className={on ? "chart-yoga is-on h-auto" : "chart-yoga h-auto"}
|
||||
onClick={() => onPicks({ yogaPick: on ? null : index })}
|
||||
>
|
||||
<span className="chart-yoga-text">
|
||||
<strong>{yoga.label}</strong>
|
||||
<small>
|
||||
{yoga.planets.map((name) => PLANET_NAME_ZH[planetGlyphKey(name) ?? "sun"]).join("、") || "—"}
|
||||
{yoga.reductions.length > 0 ? ` · ${yoga.reductions.join(";")}` : ""}
|
||||
</small>
|
||||
</span>
|
||||
<Badge variant={yoga.reductions.length > 0 ? "secondary" : "default"}>
|
||||
{yoga.reductions.length > 0 ? "部分满足" : "条件满足"}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <p className="chart-page-note">本盘没有成立的经典格局。</p>}
|
||||
{yogas.notFormed.length > 0 ? (
|
||||
<Collapsible className="chart-deep-more">
|
||||
<CollapsibleTrigger render={<Button type="button" variant="ghost" className="chart-deep-more-trigger" />}>
|
||||
未满足 {yogas.notFormed.length} 条
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul className="chart-yoga-unmet">
|
||||
{yogas.notFormed.map((yoga) => <li key={yoga.id}>{yoga.label}</li>)}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartDataCard({
|
||||
view,
|
||||
board,
|
||||
type,
|
||||
tab,
|
||||
onTab,
|
||||
picks,
|
||||
onPicks,
|
||||
pendingLayers,
|
||||
layerFailures,
|
||||
onNeedLayer,
|
||||
}: {
|
||||
view: ChartViewOk;
|
||||
board: ChartBoardState;
|
||||
/** The board's chart type: 行星 shows that chart's own table. */
|
||||
type: ChartTypeId;
|
||||
tab: ChartDataTab;
|
||||
onTab: (tab: ChartDataTab) => void;
|
||||
picks: ChartDataPicks;
|
||||
onPicks: (next: Partial<ChartDataPicks>) => void;
|
||||
pendingLayers?: ReadonlySet<ChartViewLayer>;
|
||||
layerFailures?: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;
|
||||
onNeedLayer?: (layer: ChartViewLayer) => void;
|
||||
}) {
|
||||
const layer = tabLayer(tab);
|
||||
// The open tab's lazy layer: points for the tables and karakas, ashtakavarga for 八分点.
|
||||
useEffect(() => {
|
||||
if (layer) onNeedLayer?.(layer);
|
||||
}, [layer, onNeedLayer]);
|
||||
const pointsReady = view.points?.status === "ok";
|
||||
const pointsPending = !pointsReady && (pendingLayers?.has("points") ?? false);
|
||||
const avReady = view.ashtakavarga?.status === "ok";
|
||||
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") {
|
||||
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")} />;
|
||||
} else if (tab === "strength") {
|
||||
body = <StrengthTable view={view} board={board} />;
|
||||
} else if (tab === "dasha") {
|
||||
body = <DashaTable view={view} picks={picks} onPicks={onPicks} pendingLayers={pendingLayers} layerFailures={layerFailures} onNeedLayer={onNeedLayer} />;
|
||||
} else if (tab === "relation") {
|
||||
body = pointsReady ? <RelationTable view={view} picks={picks} onPicks={onPicks} /> : <Waiting pending={pointsPending} failure={layerFailures?.get("points")} />;
|
||||
} else {
|
||||
body = <YogaList view={view} picks={picks} onPicks={onPicks} />;
|
||||
}
|
||||
return (
|
||||
<Card className="chart-board-card chart-data-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">盘面数据</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={(value) => value && onTab(value as ChartDataTab)}>
|
||||
<TabsList variant="default" aria-label="盘面数据" className="chart-data-tabs" onPointerDown={dragScroll}>
|
||||
{CHART_DATA_TABS.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="chart-data-scroll">{body}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { OnboardingDot } from "@/components/onboarding-dot";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ChartDashaTab } from "@/components/chart-page/chart-dasha-tab";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import type { ChartBoardState } from "@/hooks/use-chart-board";
|
||||
import { bodyHouse, dignityStep, houseLord } from "@/lib/chart-board-model";
|
||||
import {
|
||||
BAND_ZH,
|
||||
bhavaBand,
|
||||
bhavaMean,
|
||||
natalBoard,
|
||||
pointsOf,
|
||||
progressOf,
|
||||
savBand,
|
||||
SAV_REFERENCE,
|
||||
signLabelAt,
|
||||
vimshottariAt,
|
||||
type PointGroup,
|
||||
} from "@/lib/chart-board-view";
|
||||
import type { ChartViewFailureStatus, ChartViewOk, ChartViewTransitOk } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import { chartLayerFailureCopy } from "@/lib/chart-view-failure";
|
||||
import { STRENGTH_LEVEL_ZH } from "@/lib/chart-view-insight";
|
||||
import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels";
|
||||
import { SIGN_ORDER } from "@/lib/chart-view-labels";
|
||||
import { vimshottariSplit, type DashaSpan } from "@/lib/dasha-span";
|
||||
import { useOnboardingJourneyOptional } from "@/lib/onboarding-journey";
|
||||
import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, PLANET_NAME_ZH, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
import { PLANET_COLOR_CLASS, PLANET_GLYPH_ZH, planetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
export type ChartDeepTab = "time" | "energy" | "yoga" | "book";
|
||||
export type ChartDeepTab = "time" | "energy" | "book";
|
||||
|
||||
const SHADBALA_PARTS = [
|
||||
["sthana", "位置力"],
|
||||
@@ -203,7 +195,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
|
||||
}, [onNeedLayer]);
|
||||
return (
|
||||
<div className="chart-deep-body">
|
||||
<Section title="观测日期" hint="换日期,下面的运期、行运和盘上的行运图层一起变">
|
||||
<Section title="观测日期">
|
||||
<div className="chart-date-bar">
|
||||
<Input
|
||||
type="date"
|
||||
@@ -224,7 +216,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
|
||||
</div>
|
||||
</Section>
|
||||
<div className="chart-deep-two">
|
||||
<Section title="当前运期" hint="进度=这一层已走过多少">
|
||||
<Section title="当前运期">
|
||||
{now ? (
|
||||
<>
|
||||
<PeriodRow level="大运" span={{ lord: now.maha.lord, label: now.maha.label, start: now.maha.start, end: now.maha.end }} day={observe} />
|
||||
@@ -243,7 +235,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
|
||||
<th scope="col">星座</th>
|
||||
<th scope="col">上升起</th>
|
||||
<th scope="col">月亮起</th>
|
||||
<th scope="col">八宫点</th>
|
||||
<th scope="col">八分点</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -276,12 +268,11 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai
|
||||
<p className="chart-page-note" aria-busy={transitPending}></p>
|
||||
)}
|
||||
<div className="chart-deep-foot">
|
||||
<span>行运是这一天正午的位置,不代表事件一定发生。</span>
|
||||
<Button type="button" variant="link" size="sm" onClick={() => { board.setType("D1"); board.setLayer("transit"); }}>叠到盘面</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
<Section title="人生长卷" hint="按真实时长等比排;点一段往下展开">
|
||||
<Section title="人生长卷">
|
||||
<div className="chart-scroll-wrap">
|
||||
<div className="chart-scroll-inner">
|
||||
<ScrollRow
|
||||
@@ -333,14 +324,10 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
|
||||
pending: boolean;
|
||||
failure?: ChartViewFailureStatus;
|
||||
}) {
|
||||
const [by, setBy] = useState<"house" | "sign">("house");
|
||||
const [reading, setReading] = useState<"raw" | "trikona" | "sodhita">("raw");
|
||||
const rows = [...(view.shadbala ?? [])].sort((a, b) => b.percent - a.percent);
|
||||
const max = Math.max(10, ...rows.map((row) => row.rupas));
|
||||
const av = view.ashtakavarga?.status === "ok" ? view.ashtakavarga : null;
|
||||
const mean = bhavaMean(view);
|
||||
const natal = natalBoard(view);
|
||||
const columns = by === "house" ? natal.houses.map((house) => house.sign) : SIGN_ORDER.map((_, index) => index);
|
||||
const bhavaMax = av?.bhavaBala ? Math.max(...av.bhavaBala.map((house) => house.total), mean ?? 0) * 1.1 : 1;
|
||||
const selectedHouse = board.selection?.kind === "house" ? board.selection.house : null;
|
||||
useEffect(() => {
|
||||
@@ -349,7 +336,7 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
|
||||
return (
|
||||
<div className="chart-deep-body">
|
||||
<div className="chart-deep-two">
|
||||
<Section title="行星六力" hint="百分比 = 达到门槛的程度,竖线是门槛">
|
||||
<Section title="行星六力">
|
||||
{rows.length > 0 ? (
|
||||
<div className="chart-shadbala">
|
||||
{rows.map((row) => {
|
||||
@@ -385,11 +372,10 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
|
||||
<div className="chart-board-legend">
|
||||
{SHADBALA_PARTS.map(([id, label]) => <span key={id}><i className={`chart-shadbala-key is-${id}`} />{label}</span>)}
|
||||
</div>
|
||||
<p className="chart-board-layer-note">强 = 达标 120% 以上,中 = 90%~120%,弱 = 不到 90%。</p>
|
||||
</div>
|
||||
) : <p className="chart-page-note">这张盘没有返回六力。</p>}
|
||||
</Section>
|
||||
<Section title="宫位力量" hint="点柱子选宫">
|
||||
<Section title="宫位力量">
|
||||
{av?.bhavaBala && mean !== null ? (
|
||||
<>
|
||||
<div className="chart-bhava-bars">
|
||||
@@ -414,7 +400,6 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
|
||||
</div>
|
||||
<div className="chart-bhava-axis">{av.bhavaBala.map((house) => <span key={house.house}>{house.house}</span>)}</div>
|
||||
<div className="chart-deep-foot">
|
||||
<span>单位 {av.bhavaBalaUnit};高于均值一成记强,低一成记弱。</span>
|
||||
<Button type="button" variant="link" size="sm" onClick={() => { board.setType("D1"); board.setLayer("bhava"); }}>叠到盘面</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -422,332 +407,13 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
|
||||
: <p className="chart-page-note" aria-busy={pending}></p>}
|
||||
</Section>
|
||||
</div>
|
||||
<Section title="八宫点" hint="每行是一颗星给的点,末行合计">
|
||||
{av ? (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={by} onValueChange={(value) => value && setBy(value as "house" | "sign")}>
|
||||
<TabsList variant="default" aria-label="排列">
|
||||
<TabsTrigger value="house" className="chart-board-tab">按宫位</TabsTrigger>
|
||||
<TabsTrigger value="sign" className="chart-board-tab">按星座</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Tabs value={reading} onValueChange={(value) => value && setReading(value as "raw" | "trikona" | "sodhita")}>
|
||||
<TabsList variant="default" aria-label="口径">
|
||||
<TabsTrigger value="raw" className="chart-board-tab">原始</TabsTrigger>
|
||||
<TabsTrigger value="trikona" className="chart-board-tab">三角削减</TabsTrigger>
|
||||
<TabsTrigger value="sodhita" className="chart-board-tab">两步削减</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="chart-av-wrap">
|
||||
<Table className="chart-av">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"><span className="sr-only">星体</span></th>
|
||||
{columns.map((sign, index) => (
|
||||
<th key={`${by}-${sign}`} scope="col">{by === "house" ? <>{index + 1}宫<br /></> : null}{signZh(SIGN_ORDER[sign] ?? "")}</th>
|
||||
))}
|
||||
<th scope="col">计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reading === "raw" && av.lagnaBav ? (
|
||||
<tr className="chart-av-lagna">
|
||||
<th scope="row">升</th>
|
||||
{columns.map((sign) => <td key={sign}><span>{av.lagnaBav![sign] ?? 0}</span></td>)}
|
||||
<td className="chart-av-sum">{av.lagnaBav.reduce((sum, value) => sum + value, 0)}</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{av.bav.map((row) => {
|
||||
const values = row[reading];
|
||||
const key = planetGlyphKey(row.name);
|
||||
return (
|
||||
<tr key={row.name}>
|
||||
<th scope="row">{key ? PLANET_GLYPH_ZH[key] : row.label}</th>
|
||||
{columns.map((sign) => {
|
||||
const value = values[sign] ?? 0;
|
||||
return <td key={sign}><span className={value >= 6 ? "is-high" : value >= 5 ? "is-good" : value <= 2 ? "is-low" : undefined}>{value}</span></td>;
|
||||
})}
|
||||
<td className="chart-av-sum">{values.reduce((sum, value) => sum + value, 0)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
<tr className="chart-av-total">
|
||||
<th scope="row">总</th>
|
||||
{columns.map((sign) => {
|
||||
const total = av.bav.reduce((sum, row) => sum + (row[reading][sign] ?? 0), 0);
|
||||
return <td key={sign}><span className={reading === "raw" ? (total >= SAV_REFERENCE ? "is-good" : "is-low") : undefined}>{total}</span></td>;
|
||||
})}
|
||||
<td className="chart-av-sum">{av.bav.reduce((sum, row) => sum + row[reading].reduce((a, b) => a + b, 0), 0)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="chart-deep-foot">
|
||||
<span>{SAV_REFERENCE} 是传统参考线,不是本盘均值。「升」是上升自己的一行,不计入合计。</span>
|
||||
<Button type="button" variant="link" size="sm" onClick={() => { board.setType("D1"); board.setLayer("sav"); }}>叠到盘面</Button>
|
||||
</div>
|
||||
</>
|
||||
) : !pending && failure ? <p className="chart-page-note">{chartLayerFailureCopy(failure)}</p>
|
||||
: <p className="chart-page-note" aria-busy={pending}></p>}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YogaTab({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const yogas = view.yogas;
|
||||
const focused = board.yogaBodies?.join(",") ?? null;
|
||||
const pick = (planets: readonly string[]) => {
|
||||
const keys = planets.map((name) => planetGlyphKey(name)).filter((key): key is PlanetGlyphKey => key !== null);
|
||||
board.focusYoga(focused === keys.join(",") ? null : keys);
|
||||
document.getElementById("chart-board-anchor")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
};
|
||||
if (!yogas) return <p className="chart-page-note">这张盘没有返回格局。</p>;
|
||||
return (
|
||||
<div className="chart-deep-body">
|
||||
<Section title="星体组合" hint="状态是计算条件是否成立,不代表人生结果">
|
||||
{yogas.formed.length > 0 ? (
|
||||
<div className="chart-yoga-list">
|
||||
{yogas.formed.map((yoga) => {
|
||||
const keys = yoga.planets.map((name) => planetGlyphKey(name)).filter((key): key is PlanetGlyphKey => key !== null);
|
||||
const on = focused !== null && focused === keys.join(",");
|
||||
return (
|
||||
<Button
|
||||
key={yoga.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-pressed={on}
|
||||
className={on ? "chart-yoga is-on h-auto" : "chart-yoga h-auto"}
|
||||
onClick={() => pick(yoga.planets)}
|
||||
>
|
||||
<span className="chart-yoga-text">
|
||||
<strong>{yoga.label}</strong>
|
||||
<small>
|
||||
{yoga.planets.map((name) => PLANET_NAME_ZH[planetGlyphKey(name) ?? "sun"]).join("、") || "—"}
|
||||
{yoga.reductions.length > 0 ? ` · ${yoga.reductions.join(";")}` : ""}
|
||||
</small>
|
||||
</span>
|
||||
<Badge variant={yoga.reductions.length > 0 ? "secondary" : "default"}>
|
||||
{yoga.reductions.length > 0 ? "部分满足" : "条件满足"}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <p className="chart-page-note">本盘没有成立的经典格局。</p>}
|
||||
{yogas.notFormed.length > 0 ? (
|
||||
<Collapsible className="chart-deep-more">
|
||||
<CollapsibleTrigger render={<Button type="button" variant="ghost" className="chart-deep-more-trigger" />}>
|
||||
未满足 {yogas.notFormed.length} 条
|
||||
<ChevronDown aria-hidden="true" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul className="chart-yoga-unmet">
|
||||
{yogas.notFormed.map((yoga) => <li key={yoga.id}>{yoga.label}</li>)}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<p className="chart-board-layer-note">传统格局,本站未验证,只作参考。</p>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BookTable = "planets" | "upagraha" | "special" | "arudha" | "strength" | "relation";
|
||||
|
||||
const BOOK_TABLES: ReadonlyArray<readonly [BookTable, string]> = [
|
||||
["planets", "行星"],
|
||||
["upagraha", "虚星"],
|
||||
["special", "特殊点"],
|
||||
["arudha", "映射"],
|
||||
["strength", "星力明细"],
|
||||
["relation", "敌友"],
|
||||
];
|
||||
|
||||
const STHANA_PARTS = [["uccha", "高位力"], ["saptavarga", "七分盘力"], ["ojayugma", "奇偶力"], ["kendra", "角宫力"], ["drekkana", "三分力"]] as const;
|
||||
const KALA_PARTS = [["nathonnata", "昼夜力"], ["paksha", "月相力"], ["tribhaga", "三分时力"], ["ayana", "至力"], ["hora", "时主力"]] as const;
|
||||
const RELATION_ZH: Readonly<Record<number, string>> = { 2: "大友", 1: "友", 0: "中", [-1]: "敌", [-2]: "大敌" };
|
||||
|
||||
function PointTable({ view, board, group }: { view: ChartViewOk; board: ChartBoardState; group: PointGroup }) {
|
||||
const rows = pointsOf(view, group);
|
||||
if (!rows) return <p className="chart-page-note">这张表暂时没算出来。</p>;
|
||||
const picked = board.selection?.kind === "point" ? board.selection.key : null;
|
||||
const arudha = group === "arudha";
|
||||
return (
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">名称</th>
|
||||
<th scope="col">{arudha ? "星座" : "黄道经度"}</th>
|
||||
<th scope="col">宫位</th>
|
||||
{arudha ? <th scope="col">来自</th> : <><th scope="col">月宿</th><th scope="col">宿主</th><th scope="col">足</th></>}
|
||||
<th scope="col">传统含义</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((point) => {
|
||||
const on = picked === point.key;
|
||||
const pick = () => board.pickPoint(on ? null : point);
|
||||
return (
|
||||
<tr key={point.key} className={on ? "is-selected" : undefined} aria-selected={on} onClick={pick}>
|
||||
<th scope="row">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="chart-graha-name h-auto px-0"
|
||||
aria-pressed={on}
|
||||
onClick={(event) => { event.stopPropagation(); pick(); }}
|
||||
>
|
||||
<span className="chart-point-tag">{point.short}</span>
|
||||
{point.label}
|
||||
<small>{point.english}</small>
|
||||
</Button>
|
||||
</th>
|
||||
<td className="chart-graha-num">{point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""}</td>
|
||||
<td className="chart-graha-num">{point.house}宫</td>
|
||||
{arudha
|
||||
? <td>第 {point.sourceHouse ?? "—"} 宫 · 主 {point.sourceLord ?? "—"}</td>
|
||||
: <><td>{point.nakshatra ?? "—"}</td><td>{point.nakshatraLord ?? "—"}</td><td className="chart-graha-num">{point.pada ?? "—"}</td></>}
|
||||
<td className="chart-point-note">{point.note}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StrengthTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) {
|
||||
const [part, setPart] = useState<"total" | "sthana" | "kala" | "other">("total");
|
||||
const rows = view.shadbala ?? [];
|
||||
if (rows.length === 0) return <p className="chart-page-note">这张盘没有返回六力。</p>;
|
||||
const current = board.selection?.kind === "body" ? board.selection.key : null;
|
||||
const head = part === "total" ? ["总分", "达标分", "达标鲁帕", "满足率", "差额"]
|
||||
: part === "sthana" ? [...STHANA_PARTS.map(([, label]) => label), "小计"]
|
||||
: part === "kala" ? [...KALA_PARTS.map(([, label]) => label), "小计"]
|
||||
: ["方位力", "运动力", "自然力", "视相力"];
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={part} onValueChange={(value) => value && setPart(value as typeof part)}>
|
||||
<TabsList variant="default" aria-label="六力视图">
|
||||
<TabsTrigger value="total" className="chart-board-tab">总表</TabsTrigger>
|
||||
<TabsTrigger value="sthana" className="chart-board-tab">位置力</TabsTrigger>
|
||||
<TabsTrigger value="kala" className="chart-board-tab">时间力</TabsTrigger>
|
||||
<TabsTrigger value="other" className="chart-board-tab">其余四力</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead><tr><th scope="col">行星</th>{head.map((label) => <th key={label} scope="col">{label}</th>)}</tr></thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const on = key !== null && current === key;
|
||||
const total = row.rupas * 60;
|
||||
const required = row.required * 60;
|
||||
let cells: ReactNode;
|
||||
if (part === "total") {
|
||||
cells = (
|
||||
<>
|
||||
<td className="chart-graha-num">{total.toFixed(1)}</td>
|
||||
<td className="chart-graha-num">{required.toFixed(0)}</td>
|
||||
<td className="chart-graha-num">{row.required.toFixed(2)}</td>
|
||||
<td className="chart-graha-num">{row.percent.toFixed(1)}%<span className={`chart-band is-${row.level}`}>{STRENGTH_LEVEL_ZH[row.level]}</span></td>
|
||||
<td className={`chart-graha-num ${total >= required ? "chart-graha-good" : "chart-graha-bad"}`}>{total >= required ? "+" : ""}{(total - required).toFixed(1)}</td>
|
||||
</>
|
||||
);
|
||||
} else if (part === "other") {
|
||||
cells = row.parts
|
||||
? ["dig", "chesta", "naisargika", "drik"].map((id) => <td key={id} className="chart-graha-num">{row.parts![id as "dig"].toFixed(1)}</td>)
|
||||
: <td colSpan={4}>—</td>;
|
||||
} else {
|
||||
const list = part === "sthana" ? STHANA_PARTS : KALA_PARTS;
|
||||
const values = part === "sthana" ? row.sthanaParts : row.kalaParts;
|
||||
cells = values
|
||||
? <>
|
||||
{list.map(([id]) => <td key={id} className="chart-graha-num">{(values as Record<string, number>)[id]!.toFixed(1)}</td>)}
|
||||
<td className="chart-graha-num"><strong>{(part === "sthana" ? row.parts?.sthana : row.parts?.kala)?.toFixed(1) ?? "—"}</strong></td>
|
||||
</>
|
||||
: <td colSpan={list.length + 1}>—</td>;
|
||||
}
|
||||
return (
|
||||
<tr key={row.name} className={on ? "is-selected" : undefined} onClick={() => key && board.select(on ? null : { kind: "body", key })}>
|
||||
<th scope="row">{row.label}</th>
|
||||
{cells}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="chart-board-layer-note">单位维鲁帕,60 维鲁帕 = 1 鲁帕。图形版在「能量图谱」。</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RelationTable({ view }: { view: ChartViewOk }) {
|
||||
const [mode, setMode] = useState<"natural" | "temporary" | "compound">("compound");
|
||||
const friendship = view.points?.status === "ok" ? view.points.friendship : null;
|
||||
if (!friendship) return <p className="chart-page-note">这张表暂时没算出来。</p>;
|
||||
const names = friendship.rows.map((row) => row.name);
|
||||
const label = (value: number) => mode === "compound" ? RELATION_ZH[value] ?? "—" : value > 0 ? "友" : value < 0 ? "敌" : "中";
|
||||
return (
|
||||
<>
|
||||
<div className="chart-board-toolbar">
|
||||
<Tabs value={mode} onValueChange={(value) => value && setMode(value as typeof mode)}>
|
||||
<TabsList variant="default" aria-label="敌友口径">
|
||||
<TabsTrigger value="natural" className="chart-board-tab">天生</TabsTrigger>
|
||||
<TabsTrigger value="temporary" className="chart-board-tab">临时</TabsTrigger>
|
||||
<TabsTrigger value="compound" className="chart-board-tab">综合</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky chart-relation-table">
|
||||
<thead>
|
||||
<tr><th scope="col"><span className="sr-only">星体</span></th>{names.map((name) => <th key={name} scope="col">{PLANET_GLYPH_ZH[planetGlyphKey(name) ?? "sun"]}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{friendship.rows.map((row) => (
|
||||
<tr key={row.name}>
|
||||
<th scope="row">{row.label}</th>
|
||||
{names.map((target) => {
|
||||
const cell = row.targets.find((item) => item.name === target);
|
||||
if (!cell) return <td key={target} className="chart-relation-self">—</td>;
|
||||
const value = cell[mode];
|
||||
return <td key={target} className={value > 0 ? "chart-graha-good" : value < 0 ? "chart-graha-bad" : undefined}>{label(value)}</td>;
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="chart-board-layer-note">行是这颗星怎么看列上的星。临时关系看两星在盘上的相对位置(第 2、3、4、10、11、12 位为友);综合是两者合起来分五档。</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BookTab({ view, board, onNeedLayer }: {
|
||||
view: ChartViewOk;
|
||||
board: ChartBoardState;
|
||||
onNeedLayer?: (layer: ChartViewLayer) => void;
|
||||
}) {
|
||||
const natal = natalBoard(view);
|
||||
function BookTab({ view }: { view: ChartViewOk }) {
|
||||
const calendar = view.birthCalendar;
|
||||
const moon = view.vedic.planets.find((row) => row.name === "Moon");
|
||||
const [table, setTable] = useState<BookTable>("planets");
|
||||
// The data tables read the points layer.
|
||||
useEffect(() => {
|
||||
onNeedLayer?.("points");
|
||||
}, [onNeedLayer]);
|
||||
const pointsPending = view.points?.status !== "ok" && board.pointsPending;
|
||||
return (
|
||||
<div className="chart-deep-body">
|
||||
<Section title="出生资料与计算口径">
|
||||
@@ -770,83 +436,6 @@ function BookTab({ view, board, onNeedLayer }: {
|
||||
</dl>
|
||||
</Section>
|
||||
) : null}
|
||||
<Section title="数据表" hint="数据来自本站引擎">
|
||||
<Tabs value={table} onValueChange={(value) => value && setTable(value as BookTable)}>
|
||||
<TabsList variant="default" aria-label="数据表" className="chart-book-tabs">
|
||||
{BOOK_TABLES.map(([id, label]) => <TabsTrigger key={id} value={id} className="chart-board-tab">{label}</TabsTrigger>)}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{table === "planets" ? (
|
||||
<div className="chart-deep-two">
|
||||
<Section title="行星名册" hint="D1">
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table is-sticky">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">星体</th>
|
||||
<th scope="col">位置</th>
|
||||
<th scope="col">月宿</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">D9</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{view.vedic.planets.map((row) => {
|
||||
const key = planetGlyphKey(row.name);
|
||||
const dignity = dignityStep(row.status);
|
||||
return (
|
||||
<tr key={row.name} onClick={() => key && board.select({ kind: "body", key })}>
|
||||
<th scope="row">
|
||||
{row.label}
|
||||
</th>
|
||||
<td className="chart-graha-num">{row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫</td>
|
||||
<td>{row.nakshatra}</td>
|
||||
<td>
|
||||
<span className={dignity?.step === 0 ? "chart-graha-bad" : dignity && dignity.step >= 6 ? "chart-graha-good" : undefined}>{dignity?.label ?? "—"}</span>
|
||||
{row.retrograde && key !== "rahu" && key !== "ketu" ? " 逆" : ""}
|
||||
</td>
|
||||
<td>{row.d9SignLabel ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Section>
|
||||
<Section title="宫主流向" hint="第 N 宫之主落在哪一宫">
|
||||
<div className="chart-graha-table-wrap">
|
||||
<Table className="chart-graha-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">宫位</th>
|
||||
<th scope="col">宫主</th>
|
||||
<th scope="col">落宫</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{natal.houses.map((house) => {
|
||||
const lord = houseLord(natal, house.house);
|
||||
const at = lord ? bodyHouse(natal, lord) : null;
|
||||
return (
|
||||
<tr key={house.house} onClick={() => board.select({ kind: "house", house: house.house })}>
|
||||
<th scope="row">{house.house}宫 · {signLabelAt(house.sign)}</th>
|
||||
<td>{lord ? PLANET_NAME_ZH[lord] : "—"}</td>
|
||||
<td>{at ? `${at}宫 · ${signLabelAt(natal.houses[at - 1]!.sign)}` : "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
) : null}
|
||||
{table === "upagraha" || table === "special" || table === "arudha" ? (
|
||||
pointsPending ? <p className="chart-page-note" aria-busy="true"></p> : <PointTable view={view} board={board} group={table} />
|
||||
) : null}
|
||||
{table === "strength" ? <StrengthTable view={view} board={board} /> : null}
|
||||
{table === "relation" ? (pointsPending ? <p className="chart-page-note" aria-busy="true"></p> : <RelationTable view={view} />) : null}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -854,8 +443,7 @@ function BookTab({ view, board, onNeedLayer }: {
|
||||
const DEEP_TABS: ReadonlyArray<{ id: ChartDeepTab; label: string; hintTab?: "dasha" | "basics" }> = [
|
||||
{ id: "time", label: "时运流年", hintTab: "dasha" },
|
||||
{ id: "energy", label: "能量图谱" },
|
||||
{ id: "yoga", label: "命性格局" },
|
||||
{ id: "book", label: "排盘底册", hintTab: "basics" },
|
||||
{ id: "book", label: "出生资料与口径", hintTab: "basics" },
|
||||
];
|
||||
|
||||
export function ChartDeepReading({
|
||||
@@ -883,7 +471,6 @@ export function ChartDeepReading({
|
||||
}) {
|
||||
const journey = useOnboardingJourneyOptional();
|
||||
const now = vimshottariAt(view, view.dasha.asOf);
|
||||
const formed = view.yogas?.formed.length ?? 0;
|
||||
function select(next: ChartDeepTab) {
|
||||
onTab(next);
|
||||
}
|
||||
@@ -891,7 +478,6 @@ export function ChartDeepReading({
|
||||
<Card className="chart-board-card chart-deep" id="chart-deep">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">深读</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">时运 · 能量 · 格局 · 底册</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={tab} onValueChange={(value) => value && select(value as ChartDeepTab)}>
|
||||
@@ -900,7 +486,6 @@ export function ChartDeepReading({
|
||||
<TabsTrigger key={item.id} value={item.id} className="chart-board-tab">
|
||||
{item.label}
|
||||
{item.id === "time" && now ? <small>{PLANET_GLYPH_ZH[planetGlyphKey(now.maha.lord) ?? "sun"]}{now.antar ? ` · ${PLANET_GLYPH_ZH[planetGlyphKey(now.antar.lord) ?? "sun"]}` : ""}</small> : null}
|
||||
{item.id === "yoga" && formed > 0 ? <small>{formed} 组</small> : null}
|
||||
{item.hintTab && journey?.tabHasDot(item.hintTab) ? <OnboardingDot playKey={`tab-${item.hintTab}`} /> : null}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
@@ -923,8 +508,7 @@ export function ChartDeepReading({
|
||||
{tab === "energy" ? (
|
||||
<EnergyTab view={view} board={board} onNeedLayer={onNeedLayer} pending={pendingLayers?.has("ashtakavarga") ?? false} failure={layerFailures?.get("ashtakavarga")} />
|
||||
) : null}
|
||||
{tab === "yoga" ? <YogaTab view={view} board={board} /> : null}
|
||||
{tab === "book" ? <BookTab view={view} board={board} onNeedLayer={onNeedLayer} /> : null}
|
||||
{tab === "book" ? <BookTab view={view} /> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -180,7 +180,7 @@ function BodyDetail({ view, model, source, keyName, transitSky }: {
|
||||
<p className="chart-inspector-text">
|
||||
在{signLabelAt(transit.sign)} {formatDegreeMinutes(transit.degree)}{transit.retrograde ? "(逆行)" : ""}
|
||||
{natal ? `,本命第 ${natal.house} 宫` : ""}
|
||||
{sav !== null && sav !== undefined ? `,该座八宫点 ${sav}` : ""}
|
||||
{sav !== null && sav !== undefined ? `,该座八分点 ${sav}` : ""}
|
||||
</p>
|
||||
</Section>
|
||||
) : null}
|
||||
@@ -216,7 +216,7 @@ function HouseDetail({ view, model, source, house }: {
|
||||
</Section>
|
||||
) : null}
|
||||
{sav !== null && sav !== undefined ? (
|
||||
<Section label="八宫点" value={`${sav} 点`}>
|
||||
<Section label="八分点" value={`${sav} 点`}>
|
||||
<div className="chart-inspector-band"><Band band={savBand(sav)} /></div>
|
||||
</Section>
|
||||
) : null}
|
||||
@@ -224,6 +224,23 @@ function HouseDetail({ view, model, source, house }: {
|
||||
);
|
||||
}
|
||||
|
||||
/** With nothing picked: the ascendant and where its lord sits. */
|
||||
function AscendantSummary({ view, model }: { view: ChartViewOk; model: BoardModel | null }) {
|
||||
const asc = view.vedic.ascendant;
|
||||
if (!asc) return null;
|
||||
const lord = model ? houseLord(model, model.ascendantHouse) : null;
|
||||
const lordHouse = model && lord ? bodyHouse(model, lord) : null;
|
||||
return (
|
||||
<>
|
||||
<p className="chart-inspector-empty">上升 {asc.signLabel} {formatDegreeMinutes(asc.degreeInSign)}</p>
|
||||
<p className="chart-inspector-lead">
|
||||
{asc.nakshatra ? `${asc.nakshatra}${asc.pada ? ` · 第 ${asc.pada} 足` : ""}` : ""}
|
||||
{lord && lordHouse !== null && model ? `${asc.nakshatra ? " · " : ""}宫主${PLANET_NAME_ZH[lord]}在第 ${lordHouse} 宫 · ${signLabelAt(model.houses[lordHouse - 1]?.sign ?? -1)}` : ""}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartInspector({
|
||||
view,
|
||||
model,
|
||||
@@ -252,12 +269,9 @@ export function ChartInspector({
|
||||
<Card className="chart-board-card chart-inspector">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">盘面检视</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点星体或宫位</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="chart-inspector-empty">点盘上的星或宫看详情</p>
|
||||
<p className="chart-inspector-lead">点了之后,盘上会连出它管的宫和它看的宫,右边表格同一行也会亮。</p>
|
||||
<p className="chart-inspector-hint">方向键在九曜或十二宫间切换 · Esc 收起</p>
|
||||
<AscendantSummary view={view} model={model} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table } from "@/components/ui/table";
|
||||
import { formatDegreeMinutes } from "@/components/chart-page/chart-format";
|
||||
import { bodyHouse, dignityStep, type BoardModel, type BoardSelection } from "@/lib/chart-board-model";
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
} from "@/lib/chart-board-view";
|
||||
import type { ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import { STRENGTH_LEVEL_ZH } from "@/lib/chart-view-insight";
|
||||
import { PLANET_GLYPH_ZH, planetGlyphKey } from "@/lib/planet-glyphs";
|
||||
import { planetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
function selectedBody(selection: BoardSelection) {
|
||||
return selection?.kind === "body" ? selection.key : null;
|
||||
@@ -35,7 +34,6 @@ export function ChartGrahaCard({ view, selection, onSelect, compact = false }: {
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">九曜</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">恒星黄道 · 本命</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="chart-graha-table-wrap">
|
||||
@@ -110,7 +108,6 @@ export function ChartHouseThemesCard({ model, selection, onSelect }: {
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">宫位主题</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点一下,盘上对应的宫亮起</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="chart-theme-chips">
|
||||
{HOUSE_THEME_ZH.map((theme, index) => {
|
||||
@@ -137,53 +134,6 @@ export function ChartHouseThemesCard({ model, selection, onSelect }: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Vimshottari mahadashas; one opens to its antardashas. */
|
||||
export function ChartDashaStripCard({ view }: { view: ChartViewOk }) {
|
||||
const today = view.dasha.asOf;
|
||||
const now = vimshottariAt(view, today);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const periods = view.dasha.vimshottari.periods;
|
||||
const shown = periods.find((period) => period.start === open) ?? now?.maha ?? periods[0];
|
||||
return (
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">Vimshottari 大运</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点一段,下面看分运</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="chart-dasha-strip">
|
||||
{periods.map((period) => {
|
||||
const isNow = now?.maha.start === period.start;
|
||||
const isOpen = shown?.start === period.start;
|
||||
return (
|
||||
<Button
|
||||
key={period.start}
|
||||
type="button"
|
||||
variant="outline"
|
||||
aria-pressed={isOpen}
|
||||
className={`chart-dasha-chip h-auto${isNow ? " is-now" : ""}${isOpen ? " is-open" : ""}`}
|
||||
onClick={() => setOpen(period.start)}
|
||||
>
|
||||
<small>{period.start.slice(0, 4)}–{period.end.slice(0, 4)}</small>
|
||||
<strong>{period.label}</strong>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{shown ? (
|
||||
<div className="chart-chip-row chart-dasha-antars" aria-label={`${shown.label}大运的分运`}>
|
||||
{shown.antardashas.map((antar) => (
|
||||
<Badge key={antar.start} variant="outline" className={antar.start <= today && today < antar.end ? "chart-chip is-on" : "chart-chip"}>
|
||||
{(() => { const key = planetGlyphKey(antar.lord); return key ? PLANET_GLYPH_ZH[key] : antar.label; })()} {antar.start.slice(0, 7).replace("-", ".")}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartGlanceCard({ view, model, onSelect, onOpenTime }: {
|
||||
view: ChartViewOk;
|
||||
model: BoardModel;
|
||||
@@ -209,7 +159,6 @@ export function ChartGlanceCard({ view, model, onSelect, onOpenTime }: {
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">命盘速览</CardTitle>
|
||||
<CardAction className="chart-board-card-hint">点一项,在盘上定位</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="chart-glance">
|
||||
{glance.ascendant ? item("asc", "上升", glance.ascendant.sign, formatDegreeMinutes(glance.ascendant.degree), () => onSelect({ kind: "house", house: model.ascendantHouse })) : null}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
BhavaTables,
|
||||
NatalTable,
|
||||
@@ -11,13 +10,13 @@ import {
|
||||
VargaTable,
|
||||
} from "@/components/chart-page/chart-vedic-tab";
|
||||
import type { ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels";
|
||||
import type { ChartTypeId } from "@/lib/chart-view-labels";
|
||||
|
||||
/**
|
||||
* The selected chart type's own table, under the inspector. D1 has none here:
|
||||
* the 九曜 card is the natal table.
|
||||
* The selected chart type's own table, in the 「盘面数据」 行星 tab. D1 has none
|
||||
* here: that tab draws the natal table itself.
|
||||
*/
|
||||
export function ChartTypeTable({ view, type }: { view: ChartViewOk; type: ChartTypeId }) {
|
||||
export function ChartTypeTableBody({ view, type }: { view: ChartViewOk; type: ChartTypeId }) {
|
||||
if (type === "D1") return null;
|
||||
let body: ReactNode = null;
|
||||
let title = "";
|
||||
@@ -33,20 +32,10 @@ export function ChartTypeTable({ view, type }: { view: ChartViewOk; type: ChartT
|
||||
);
|
||||
} else if (type === "BHAVA" && view.bhava.status === "ok") {
|
||||
title = "Bhava 盘";
|
||||
body = (
|
||||
<>
|
||||
<p className="chart-page-note chart-page-table-note">{CHART_VIEW_COPY.bhavaNote}</p>
|
||||
<BhavaTables bhava={view.bhava} />
|
||||
</>
|
||||
);
|
||||
body = <BhavaTables bhava={view.bhava} />;
|
||||
} else if (type === "TRANSIT" && view.transit.status === "ok") {
|
||||
title = "行运";
|
||||
body = (
|
||||
<>
|
||||
<p className="chart-page-note chart-page-table-note">{CHART_VIEW_COPY.transitNote}</p>
|
||||
<TransitTable transit={view.transit} />
|
||||
</>
|
||||
);
|
||||
body = <TransitTable transit={view.transit} />;
|
||||
} else {
|
||||
const varga = view.vedic.vargas.find((item) => item.id === type);
|
||||
if (varga?.rows) {
|
||||
@@ -61,11 +50,9 @@ export function ChartTypeTable({ view, type }: { view: ChartViewOk; type: ChartT
|
||||
}
|
||||
if (!body) return null;
|
||||
return (
|
||||
<Card className="chart-board-card">
|
||||
<CardHeader className="chart-board-card-header">
|
||||
<CardTitle className="chart-board-card-title">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{body}</CardContent>
|
||||
</Card>
|
||||
<div className="chart-type-table">
|
||||
<h3 className="chart-type-table-title">{title}</h3>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -12,22 +12,27 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry";
|
||||
import { ChartBoardCard } from "@/components/chart-board/chart-board-card";
|
||||
import { ChartDataCard } from "@/components/chart-board/chart-data-card";
|
||||
import { ChartDeepReading, type ChartDeepTab } from "@/components/chart-board/chart-deep-reading";
|
||||
import { ChartInspector } from "@/components/chart-board/chart-inspector";
|
||||
import {
|
||||
ChartDashaStripCard,
|
||||
ChartGlanceCard,
|
||||
ChartGrahaCard,
|
||||
ChartHouseThemesCard,
|
||||
ChartTags,
|
||||
} from "@/components/chart-board/chart-side-cards";
|
||||
import { ChartTypeTable } from "@/components/chart-board/chart-type-table";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover";
|
||||
import { useChartBoard } from "@/hooks/use-chart-board";
|
||||
import { useChartPage } from "@/hooks/use-chart-page";
|
||||
import { useTransitAt } from "@/hooks/use-transit-at";
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import {
|
||||
boardFocusFor,
|
||||
DEFAULT_DATA_PICKS,
|
||||
type ChartDataPicks,
|
||||
type ChartDataTab,
|
||||
} from "@/lib/chart-board-focus";
|
||||
import type { ChartBoardLayer, PointGroup } from "@/lib/chart-board-view";
|
||||
import {
|
||||
type ChartViewFailureStatus,
|
||||
type ChartViewLagnaPrecheck,
|
||||
@@ -123,10 +128,16 @@ function journeyTabOf(system: ChartSystem, deep: ChartDeepTab | null): ChartView
|
||||
return "vedic";
|
||||
}
|
||||
|
||||
const POINT_TABS: ReadonlySet<ChartDataTab> = new Set(["upagraha", "special", "arudha"]);
|
||||
/** Data tabs that own a board layer; leaving one puts the board back on 基础星盘. */
|
||||
const LAYER_TABS: ReadonlySet<ChartDataTab> = new Set(["upagraha", "special", "arudha", "av"]);
|
||||
|
||||
/**
|
||||
* One page (issue #114 Q2 A): board and inspector beside the 九曜 table, house
|
||||
* themes, dasha strip and summary; the 「深读」 tabs below. 西洋盘 stays as its
|
||||
* own page behind the system switch (Q4).
|
||||
* One page (issue #114 Q2 A, re-laid 2026-10-30): the board beside the
|
||||
* 「盘面数据」 card, the inspector beside house themes and the summary, the
|
||||
* 「深读」 tabs below. Every card keeps a fixed height and scrolls inside, so
|
||||
* switching a tab never moves the page. 西洋盘 stays as its own page behind
|
||||
* the system switch (Q4).
|
||||
*/
|
||||
function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, deepTab, onDeepTab, onAsk }: {
|
||||
onAsk: (question: string) => void;
|
||||
@@ -141,6 +152,45 @@ function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, deepT
|
||||
const natalModel = board.type === "D1" ? board.model : null;
|
||||
const [observe, setObserve] = useState(view.dasha.asOf);
|
||||
const transit = useTransitAt(view, observe, board.needsTransit || deepTab === "time", layerFailures?.has("transit") ?? false);
|
||||
const [dataTab, setDataTab] = useState<ChartDataTab>("planets");
|
||||
const [picks, setPicks] = useState<ChartDataPicks>(DEFAULT_DATA_PICKS);
|
||||
const { setLayer, setPointLayer, setType, select } = board;
|
||||
const focus = useMemo(
|
||||
() => (natalModel ? boardFocusFor(view, natalModel, dataTab, picks) : null),
|
||||
[dataTab, natalModel, picks, view],
|
||||
);
|
||||
|
||||
/** A data tab sets the board layer it owns; leaving one goes back to 基础星盘. */
|
||||
const selectDataTab = useCallback((next: ChartDataTab) => {
|
||||
setDataTab(next);
|
||||
if (POINT_TABS.has(next)) {
|
||||
setLayer("none");
|
||||
setPointLayer(next as PointGroup);
|
||||
} else if (next === "av") {
|
||||
setPointLayer("none");
|
||||
setLayer("sav");
|
||||
} else if (LAYER_TABS.has(dataTab)) {
|
||||
setPointLayer("none");
|
||||
setLayer("none");
|
||||
}
|
||||
// 八分点 and the marked tabs draw on the natal board, and a selection would hide the marks.
|
||||
if (next === "av" || next === "strength" || next === "dasha" || next === "relation" || next === "yoga") {
|
||||
setType("D1");
|
||||
select(null);
|
||||
}
|
||||
}, [dataTab, select, setLayer, setPointLayer, setType]);
|
||||
|
||||
/** And the other way: a board layer opens its data tab; any other layer leaves it. */
|
||||
const selectLayer = useCallback((next: ChartBoardLayer) => {
|
||||
setLayer(next);
|
||||
if (next === "sav") setDataTab("av");
|
||||
else if (LAYER_TABS.has(dataTab)) setDataTab("planets");
|
||||
}, [dataTab, setLayer]);
|
||||
const selectPointLayer = useCallback((next: PointGroup | "none") => {
|
||||
setPointLayer(next);
|
||||
if (next !== "none") setDataTab(next);
|
||||
else if (LAYER_TABS.has(dataTab)) setDataTab("planets");
|
||||
}, [dataTab, setPointLayer]);
|
||||
|
||||
function openTime() {
|
||||
onDeepTab("time");
|
||||
@@ -153,7 +203,7 @@ function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, deepT
|
||||
<ChartTags view={view} model={natalModel ?? board.model} selection={board.selection} onSelect={board.select} />
|
||||
) : null}
|
||||
<div className="chart-workspace-grid">
|
||||
<div className="chart-workspace-col" id="chart-board-anchor" data-onboarding-anchor="chart-tab">
|
||||
<div className="chart-workspace-cell" id="chart-board-anchor" data-onboarding-anchor="chart-tab">
|
||||
<ChartBoardCard
|
||||
view={view}
|
||||
type={board.type}
|
||||
@@ -161,7 +211,7 @@ function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, deepT
|
||||
source={board.source}
|
||||
model={board.model}
|
||||
layer={board.layer}
|
||||
onLayer={board.setLayer}
|
||||
onLayer={selectLayer}
|
||||
style={board.style}
|
||||
onStyle={board.setStyle}
|
||||
selection={board.selection}
|
||||
@@ -170,33 +220,44 @@ function VedicWorkspace({ view, pendingLayers, layerFailures, onNeedLayer, deepT
|
||||
failure={board.typeFailure}
|
||||
layerPending={board.layerPending}
|
||||
layerFailure={board.layerFailure}
|
||||
dimmedBodies={board.dimmedBodies}
|
||||
pointLayer={board.pointLayer}
|
||||
onPointLayer={board.setPointLayer}
|
||||
onPointLayer={selectPointLayer}
|
||||
pointMarks={board.pointMarks}
|
||||
onPointSelect={board.selectPoint}
|
||||
pointsPending={board.pointsPending}
|
||||
pointsFailure={board.pointsFailure}
|
||||
transit={transit.transit}
|
||||
transitPending={transit.pending}
|
||||
focus={focus}
|
||||
/>
|
||||
<ChartInspector
|
||||
transit={transit.transit}
|
||||
view={view}
|
||||
model={board.model}
|
||||
source={board.source}
|
||||
selection={board.selection}
|
||||
onSelect={board.select}
|
||||
onStep={board.step}
|
||||
onAsk={onAsk}
|
||||
onLocateD9={board.type === "D9" ? undefined : () => board.setType("D9")}
|
||||
/>
|
||||
<ChartTypeTable view={view} type={board.type} />
|
||||
</div>
|
||||
<div className="chart-workspace-col">
|
||||
<ChartGrahaCard view={view} selection={board.selection} onSelect={board.select} />
|
||||
<div className="chart-workspace-cell chart-data-slot">
|
||||
<ChartDataCard
|
||||
view={view}
|
||||
board={board}
|
||||
type={board.type}
|
||||
tab={dataTab}
|
||||
onTab={selectDataTab}
|
||||
picks={picks}
|
||||
onPicks={(next) => setPicks((current) => ({ ...current, ...next }))}
|
||||
pendingLayers={pendingLayers}
|
||||
layerFailures={layerFailures}
|
||||
onNeedLayer={onNeedLayer}
|
||||
/>
|
||||
</div>
|
||||
<ChartInspector
|
||||
transit={transit.transit}
|
||||
view={view}
|
||||
model={board.model}
|
||||
source={board.source}
|
||||
selection={board.selection}
|
||||
onSelect={board.select}
|
||||
onStep={board.step}
|
||||
onAsk={onAsk}
|
||||
onLocateD9={board.type === "D9" ? undefined : () => board.setType("D9")}
|
||||
/>
|
||||
<div className="chart-side-stack">
|
||||
{board.model ? <ChartHouseThemesCard model={board.model} selection={board.selection} onSelect={board.select} /> : null}
|
||||
<ChartDashaStripCard view={view} />
|
||||
{board.model ? <ChartGlanceCard view={view} model={natalModel ?? board.model} onSelect={board.select} onOpenTime={openTime} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,6 @@ function PanelBoard({ view, pendingLayers, layerFailures, onNeedLayer, onAsked }
|
||||
failure={board.typeFailure}
|
||||
layerPending={board.layerPending}
|
||||
layerFailure={board.layerFailure}
|
||||
dimmedBodies={board.dimmedBodies}
|
||||
pointLayer={board.pointLayer}
|
||||
onPointLayer={board.setPointLayer}
|
||||
pointMarks={board.pointMarks}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import type { ChartViewFailureStatus, ChartViewOk, ChartViewPoint } from "@/lib/chart-view-contract";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
import type { ChartTypeId } from "@/lib/chart-view-labels";
|
||||
import type { PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
import { useUiStore } from "@/stores/ui-store";
|
||||
|
||||
/** The lazy layer a chart type needs; D1 and the Moon chart come with the page. */
|
||||
@@ -56,7 +55,6 @@ export function useChartBoard({
|
||||
const [type, setTypeState] = useState<ChartTypeId>("D1");
|
||||
const [selection, setSelection] = useState<BoardSelection>(null);
|
||||
const [layer, setLayerState] = useState<ChartBoardLayer>("none");
|
||||
const [yogaBodies, setYogaBodies] = useState<readonly PlanetGlyphKey[] | null>(null);
|
||||
/** 标点 layer: every point of one group drawn on the board at once (issue #114). */
|
||||
const [pointLayer, setPointLayerState] = useState<PointGroup | "none">("none");
|
||||
|
||||
@@ -81,8 +79,7 @@ export function useChartBoard({
|
||||
|
||||
const select = useCallback((next: BoardSelection) => {
|
||||
setSelection(next);
|
||||
setYogaBodies(null);
|
||||
// The inspector's 「此刻行运」, 宫力 and 八宫点 read these layers.
|
||||
// The inspector's 「此刻行运」, 宫力 and 八分点 read these layers.
|
||||
if (next?.kind === "body") onNeedLayer?.("transit");
|
||||
if (next?.kind === "house") onNeedLayer?.("ashtakavarga");
|
||||
}, [onNeedLayer]);
|
||||
@@ -121,47 +118,31 @@ export function useChartBoard({
|
||||
const row = group ? pointsOf(view, group)?.find((item) => item.key === key) : null;
|
||||
if (!row) return;
|
||||
setTypeState("D1");
|
||||
setYogaBodies(null);
|
||||
setSelection((current) => (current?.kind === "point" && current.key === key ? null : pointSelection(row)));
|
||||
}, [pointLayer, selection, view]);
|
||||
|
||||
/** A row in the 虚星 / 特殊点 / 映射 tables; null clears. */
|
||||
const pickPoint = useCallback((point: ChartViewPoint | null) => {
|
||||
setYogaBodies(null);
|
||||
if (!point) { setSelection(null); return; }
|
||||
setTypeState("D1");
|
||||
setSelection(pointSelection(point));
|
||||
}, []);
|
||||
|
||||
const focusYoga = useCallback((bodies: readonly PlanetGlyphKey[] | null) => {
|
||||
setSelection(null);
|
||||
setYogaBodies(bodies && bodies.length > 0 ? bodies : null);
|
||||
setTypeState("D1");
|
||||
}, []);
|
||||
|
||||
/** ← / → step, Esc clears; ignored while typing. */
|
||||
const onKeyDown = useCallback((event: KeyboardEvent<HTMLElement>) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest?.("input, textarea, [contenteditable='true']")) return;
|
||||
if (event.key === "Escape" && (selection || yogaBodies)) {
|
||||
if (event.key === "Escape" && selection) {
|
||||
setSelection(null);
|
||||
setYogaBodies(null);
|
||||
return;
|
||||
}
|
||||
if (!selection) return;
|
||||
if (event.key === "ArrowRight") { event.preventDefault(); step(1); }
|
||||
if (event.key === "ArrowLeft") { event.preventDefault(); step(-1); }
|
||||
}, [selection, step, yogaBodies]);
|
||||
}, [selection, step]);
|
||||
|
||||
const typeLayer = chartTypeLayer(type);
|
||||
const overlay = overlayLayer(layer);
|
||||
const dimmedBodies = useMemo(() => {
|
||||
if (!yogaBodies || !model) return undefined;
|
||||
const keep = new Set(yogaBodies);
|
||||
const dim = new Set<PlanetGlyphKey>();
|
||||
for (const house of model.houses) for (const body of house.bodies) if (!keep.has(body.key)) dim.add(body.key);
|
||||
return dim;
|
||||
}, [model, yogaBodies]);
|
||||
|
||||
return {
|
||||
type,
|
||||
@@ -176,9 +157,6 @@ export function useChartBoard({
|
||||
style,
|
||||
setStyle,
|
||||
onKeyDown,
|
||||
yogaBodies,
|
||||
focusYoga,
|
||||
dimmedBodies,
|
||||
pointLayer,
|
||||
setPointLayer,
|
||||
pointMarks,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* What the board shows for the open 「盘面数据」 tab (2026-10-30): which bodies
|
||||
* stay lit, a short mark under a body, houses tinted, and dashed links between
|
||||
* bodies. The tables and the board read the same engine rows; nothing here is
|
||||
* recomputed astrology beyond sign lordship, which the board already uses.
|
||||
*/
|
||||
|
||||
import { bodyHouse, lordedHouses, signIndex, type BoardModel } from "@/lib/chart-board-model";
|
||||
import { vimshottariAt } from "@/lib/chart-board-view";
|
||||
import type { ChartViewOk } from "@/lib/chart-view-contract";
|
||||
import { vimshottariSplit } from "@/lib/dasha-span";
|
||||
import { PLANET_GLYPH_KEYS, planetGlyphKey, type PlanetGlyphKey } from "@/lib/planet-glyphs";
|
||||
|
||||
export const CHART_DATA_TABS = [
|
||||
["planets", "行星"],
|
||||
["upagraha", "虚星"],
|
||||
["special", "特殊点"],
|
||||
["arudha", "映射"],
|
||||
["av", "八分点"],
|
||||
["strength", "星力"],
|
||||
["dasha", "大运"],
|
||||
["relation", "敌友"],
|
||||
["yoga", "瑜伽"],
|
||||
] as const;
|
||||
|
||||
export type ChartDataTab = (typeof CHART_DATA_TABS)[number][0];
|
||||
|
||||
export type DashaSystem = "vimshottari" | "chara" | "yogini";
|
||||
/** `start` identifies the row; `lord` (a planet) or `sign` (Chara) is what the board shows. */
|
||||
export type DashaPick = { start: string; level: string; lord: string | null; sign: string | null };
|
||||
export type RelationMode = "natural" | "temporary" | "compound";
|
||||
|
||||
/** The data card's own choices that the board follows. */
|
||||
export type ChartDataPicks = {
|
||||
dashaSystem: DashaSystem;
|
||||
/** A period picked in the shown system (any level); null = the current periods. */
|
||||
dashaPick: DashaPick | null;
|
||||
relationMode: RelationMode;
|
||||
relationOf: PlanetGlyphKey;
|
||||
relationTo: PlanetGlyphKey | null;
|
||||
/** Index into the formed yogas; null = none picked. */
|
||||
yogaPick: number | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_DATA_PICKS: ChartDataPicks = {
|
||||
dashaSystem: "vimshottari",
|
||||
dashaPick: null,
|
||||
relationMode: "compound",
|
||||
relationOf: "moon",
|
||||
relationTo: null,
|
||||
yogaPick: 0,
|
||||
};
|
||||
|
||||
export type FocusTone = "good" | "bad" | "warn" | "action" | "plain";
|
||||
/** A word under a body; `paint` also colours the glyph. */
|
||||
export type FocusMark = { text: string; tone: FocusTone; paint?: boolean };
|
||||
export type FocusLink = { from: PlanetGlyphKey; to: PlanetGlyphKey; tone: FocusTone };
|
||||
|
||||
export type BoardFocus = {
|
||||
/** Drawn as picked. */
|
||||
lit: ReadonlySet<PlanetGlyphKey>;
|
||||
/** Drawn faint. */
|
||||
dimmed: ReadonlySet<PlanetGlyphKey>;
|
||||
marks: ReadonlyMap<PlanetGlyphKey, FocusMark>;
|
||||
houses: ReadonlySet<number>;
|
||||
links: readonly FocusLink[];
|
||||
};
|
||||
|
||||
export const RELATION_ZH: Readonly<Record<number, string>> = { 2: "大友", 1: "友", 0: "中", [-1]: "敌", [-2]: "大敌" };
|
||||
|
||||
export function relationLabel(value: number, mode: RelationMode): string {
|
||||
if (mode === "compound") return RELATION_ZH[value] ?? "—";
|
||||
return value > 0 ? "友" : value < 0 ? "敌" : "中";
|
||||
}
|
||||
|
||||
export function relationTone(value: number): FocusTone {
|
||||
return value > 0 ? "good" : value < 0 ? "bad" : "plain";
|
||||
}
|
||||
|
||||
/** Natural, temporary or compound value of `from` towards `to`; null when the engine gave none. */
|
||||
export function relationValue(view: ChartViewOk, from: PlanetGlyphKey, to: PlanetGlyphKey, mode: RelationMode): number | null {
|
||||
const friendship = view.points?.status === "ok" ? view.points.friendship : null;
|
||||
const row = friendship?.rows.find((item) => planetGlyphKey(item.name) === from);
|
||||
const cell = row?.targets.find((item) => planetGlyphKey(item.name) === to);
|
||||
return cell ? cell[mode] : null;
|
||||
}
|
||||
|
||||
export function dashaTrack(view: ChartViewOk, system: DashaSystem) {
|
||||
return view.dasha[system];
|
||||
}
|
||||
|
||||
function onBoard(model: BoardModel): PlanetGlyphKey[] {
|
||||
return PLANET_GLYPH_KEYS.filter((key) => bodyHouse(model, key) !== null);
|
||||
}
|
||||
|
||||
function houseOfSign(model: BoardModel, sign: string | null): number | null {
|
||||
if (!sign) return null;
|
||||
const index = signIndex(sign);
|
||||
return model.houses.find((house) => house.sign === index)?.house ?? null;
|
||||
}
|
||||
|
||||
function focus(model: BoardModel, parts: {
|
||||
lit?: PlanetGlyphKey[];
|
||||
keep?: PlanetGlyphKey[];
|
||||
marks?: Map<PlanetGlyphKey, FocusMark>;
|
||||
houses?: number[];
|
||||
links?: FocusLink[];
|
||||
}): BoardFocus {
|
||||
const lit = new Set(parts.lit ?? []);
|
||||
const keep = new Set([...lit, ...(parts.keep ?? []), ...(parts.marks?.keys() ?? [])]);
|
||||
return {
|
||||
lit,
|
||||
dimmed: new Set(onBoard(model).filter((key) => !keep.has(key))),
|
||||
marks: parts.marks ?? new Map(),
|
||||
houses: new Set(parts.houses ?? []),
|
||||
links: (parts.links ?? []).filter((link) => {
|
||||
const a = bodyHouse(model, link.from);
|
||||
const b = bodyHouse(model, link.to);
|
||||
return a !== null && b !== null && a !== b;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function chain(keys: readonly PlanetGlyphKey[], tone: FocusTone): FocusLink[] {
|
||||
return keys.slice(1).map((key, index) => ({ from: keys[index]!, to: key, tone }));
|
||||
}
|
||||
|
||||
function dashaFocus(view: ChartViewOk, model: BoardModel, picks: ChartDataPicks): BoardFocus | null {
|
||||
const day = view.dasha.asOf;
|
||||
const track = dashaTrack(view, picks.dashaSystem);
|
||||
const picked = picks.dashaPick;
|
||||
if (picks.dashaSystem === "chara") {
|
||||
// Chara periods are signs: tint the sign's house.
|
||||
const sign = picked ? picked.sign : track.periods.find((item) => item.start <= day && day < item.end)?.sign ?? null;
|
||||
const house = houseOfSign(model, sign);
|
||||
return house === null ? null : focus(model, { keep: onBoard(model), houses: [house] });
|
||||
}
|
||||
if (picked) {
|
||||
const key = picked.lord ? planetGlyphKey(picked.lord) : null;
|
||||
if (!key) return null;
|
||||
return focus(model, { lit: [key], marks: new Map([[key, { text: "运主", tone: "action" }]]), houses: lordedHouses(model, key) });
|
||||
}
|
||||
const levels: Array<[string | undefined, string]> = [];
|
||||
if (picks.dashaSystem === "vimshottari") {
|
||||
const now = vimshottariAt(view, day);
|
||||
const pratyantar = now?.antar
|
||||
? vimshottariSplit(now.antar.lord, now.antar.start, now.antar.end).find((span) => span.start <= day && day < span.end)
|
||||
: undefined;
|
||||
levels.push([now?.maha.lord, "大运"], [now?.antar?.lord, "分运"], [pratyantar?.lord, "小运"]);
|
||||
} else {
|
||||
const maha = track.periods.find((item) => item.start <= day && day < item.end);
|
||||
const antar = maha?.antardashas.find((item) => item.start <= day && day < item.end);
|
||||
levels.push([maha?.lord, "大运"], [antar?.lord, "分运"]);
|
||||
}
|
||||
const marks = new Map<PlanetGlyphKey, FocusMark>();
|
||||
const keys: PlanetGlyphKey[] = [];
|
||||
for (const [lord, label] of levels) {
|
||||
const key = lord ? planetGlyphKey(lord) : null;
|
||||
if (!key) continue;
|
||||
keys.push(key);
|
||||
// One body ruling two levels keeps the higher one.
|
||||
if (!marks.has(key)) marks.set(key, { text: label, tone: "action", paint: true });
|
||||
}
|
||||
if (keys.length === 0) return null;
|
||||
return focus(model, { marks, houses: lordedHouses(model, keys[0]!), links: chain(keys, "action") });
|
||||
}
|
||||
|
||||
/**
|
||||
* The board's focus for a data tab on the natal D1, or null when the tab
|
||||
* leaves the board as it is (行星 and the point tabs use the selection and
|
||||
* the point layer instead).
|
||||
*/
|
||||
export function boardFocusFor(view: ChartViewOk, model: BoardModel, tab: ChartDataTab, picks: ChartDataPicks): BoardFocus | null {
|
||||
if (tab === "strength") {
|
||||
const marks = new Map<PlanetGlyphKey, FocusMark>();
|
||||
for (const row of view.shadbala ?? []) {
|
||||
const key = planetGlyphKey(row.name);
|
||||
if (key) marks.set(key, { text: `${Math.round(row.percent)}%`, tone: row.percent >= 100 ? "good" : "warn" });
|
||||
}
|
||||
return marks.size > 0 ? focus(model, { marks }) : null;
|
||||
}
|
||||
if (tab === "dasha") return dashaFocus(view, model, picks);
|
||||
if (tab === "relation") {
|
||||
const from = picks.relationOf;
|
||||
const marks = new Map<PlanetGlyphKey, FocusMark>();
|
||||
for (const key of onBoard(model)) {
|
||||
if (key === from) continue;
|
||||
const value = relationValue(view, from, key, picks.relationMode);
|
||||
if (value !== null) marks.set(key, { text: relationLabel(value, picks.relationMode), tone: relationTone(value), paint: true });
|
||||
}
|
||||
if (marks.size === 0) return null;
|
||||
const to = picks.relationTo;
|
||||
const value = to ? relationValue(view, from, to, picks.relationMode) : null;
|
||||
return focus(model, {
|
||||
lit: [from],
|
||||
marks,
|
||||
links: to && value !== null ? [{ from, to, tone: relationTone(value) }] : [],
|
||||
});
|
||||
}
|
||||
if (tab === "yoga") {
|
||||
const yoga = picks.yogaPick === null ? null : view.yogas?.formed[picks.yogaPick] ?? null;
|
||||
if (!yoga) return null;
|
||||
const keys = yoga.planets.map((name) => planetGlyphKey(name)).filter((key): key is PlanetGlyphKey => key !== null);
|
||||
if (keys.length === 0) return null;
|
||||
const houses = keys.map((key) => bodyHouse(model, key)).filter((house): house is number => house !== null);
|
||||
return focus(model, { lit: keys, houses, links: chain(keys, "action") });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -344,12 +344,20 @@ 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 chartViewKarakaSchema = z.object({
|
||||
key: z.enum(CHARA_KARAKA_KEYS),
|
||||
planet: z.string().min(1),
|
||||
});
|
||||
export const chartViewPointsOkSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
upagrahas: z.array(chartViewPointSchema).nullable(),
|
||||
special: z.array(chartViewPointSchema).nullable(),
|
||||
arudha: z.array(chartViewPointSchema).nullable(),
|
||||
friendship: chartViewFriendshipSchema.nullable(),
|
||||
// Optional so a points layer cached before the karaka column still parses.
|
||||
karakas: z.array(chartViewKarakaSchema).nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -505,6 +513,7 @@ export type ChartViewBirthCalendar = z.infer<typeof chartViewBirthCalendarSchema
|
||||
export type ChartViewAshtakavargaOk = z.infer<typeof chartViewAshtakavargaOkSchema>;
|
||||
export type ChartViewPointsOk = z.infer<typeof chartViewPointsOkSchema>;
|
||||
export type ChartViewPoint = z.infer<typeof chartViewPointSchema>;
|
||||
export type ChartViewKaraka = z.infer<typeof chartViewKarakaSchema>;
|
||||
|
||||
export const CHART_VIEW_TABS = [
|
||||
{ id: "vedic", label: "星盘" },
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
signZh,
|
||||
} from "./chart-view-labels.ts";
|
||||
import { panchangaNameZh } from "./panchanga-names-zh.ts";
|
||||
import { CHARA_KARAKA_KEYS } from "./chart-view-contract.ts";
|
||||
import type {
|
||||
ChartViewKaraka,
|
||||
ChartViewPoint,
|
||||
ChartViewPointsOk,
|
||||
ChartViewAshtakavargaOk,
|
||||
@@ -176,7 +178,7 @@ function twelve(value: unknown): number[] | null {
|
||||
}
|
||||
|
||||
export function unavailableAshtakavarga(reason: string): ChartViewUnavailableLayer {
|
||||
return { status: "unavailable", reason, copy: "八宫点暂时没算出来,稍后再试。" };
|
||||
return { status: "unavailable", reason, copy: "八分点暂时没算出来,稍后再试。" };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,6 +378,18 @@ 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: "这几张表暂时没算出来,稍后再试。" };
|
||||
}
|
||||
@@ -391,6 +405,7 @@ export function mapPoints(packet: Record<string, unknown> | null): ChartViewPoin
|
||||
special: special || yogi ? [...(special ?? []), ...(yogi ?? [])] : null,
|
||||
arudha: arudhaRows(packet.arudha_padas),
|
||||
friendship: mapFriendship(packet.friendship),
|
||||
karakas: karakaRows(packet.chara_karakas),
|
||||
};
|
||||
if (!layer.upagrahas && !layer.special && !layer.arudha && !layer.friendship) return unavailablePoints("points_all_blocked");
|
||||
return layer;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PointerEvent as ReactPointerEvent } from "react";
|
||||
|
||||
/**
|
||||
* Mouse drag scrolls a wide table or tab row sideways; touch and pens already
|
||||
* scroll natively. A drag longer than a few pixels swallows the click it ends
|
||||
* with, so letting go over a row does not also pick it.
|
||||
*/
|
||||
export function dragScroll(event: ReactPointerEvent<HTMLElement>) {
|
||||
if (event.pointerType !== "mouse" || event.button !== 0) return;
|
||||
const box = event.currentTarget;
|
||||
if (box.scrollWidth <= box.clientWidth) return;
|
||||
const startX = event.clientX;
|
||||
const startLeft = box.scrollLeft;
|
||||
let moved = false;
|
||||
const move = (next: PointerEvent) => {
|
||||
const dx = next.clientX - startX;
|
||||
if (Math.abs(dx) > 4) moved = true;
|
||||
box.scrollLeft = startLeft - dx;
|
||||
};
|
||||
const up = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
window.removeEventListener("pointercancel", up);
|
||||
if (!moved) return;
|
||||
const swallow = (click: MouseEvent) => {
|
||||
click.stopPropagation();
|
||||
click.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", swallow, true);
|
||||
window.setTimeout(() => window.removeEventListener("click", swallow, true), 0);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
window.addEventListener("pointercancel", up);
|
||||
}
|
||||
@@ -272,7 +272,11 @@ test("the inspector shows the number with its band, and 问 AI only writes a que
|
||||
const empty = renderToStaticMarkup(React.createElement(ChartInspector, {
|
||||
view, transit: null, model, source, selection: null, onSelect: () => {}, onStep: () => {}, onAsk: () => {},
|
||||
}));
|
||||
assert.match(empty, /点盘上的星或宫看详情/);
|
||||
// 原值:/点盘上的星或宫看详情/ 加一段操作说明;新值:没选中时写上升和上升主星落宫
|
||||
// 原因:2026-10-30 产品要求星盘页不放提示类小字
|
||||
assert.match(empty, new RegExp(`上升 ${view.vedic.ascendant!.signLabel}`));
|
||||
assert.match(empty, /宫主.+在第 \d+ 宫/);
|
||||
assert.doesNotMatch(empty, /点盘上|方向键|Esc/);
|
||||
assert.doesNotMatch(empty, /正在加载|spinner/i);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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 { ChartBoardSvg } from "../src/components/chart-board/chart-board-svg.tsx";
|
||||
import { ChartDataCard } from "../src/components/chart-board/chart-data-card.tsx";
|
||||
import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx";
|
||||
import { SidebarProvider } from "../src/components/ui/sidebar.tsx";
|
||||
import type { ChartBoardState } from "../src/hooks/use-chart-board.ts";
|
||||
import { boardFocusFor, CHART_DATA_TABS, DEFAULT_DATA_PICKS, relationValue, type BoardFocus, type FocusMark } from "../src/lib/chart-board-focus.ts";
|
||||
import { bodyHouse, lordedHouses } from "../src/lib/chart-board-model.ts";
|
||||
import { natalBoard, vimshottariAt } from "../src/lib/chart-board-view.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";
|
||||
import { vimshottariSplit } from "../src/lib/dasha-span.ts";
|
||||
import { planetGlyphKey, type PlanetGlyphKey } from "../src/lib/planet-glyphs.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.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: { planets: Record<string, { lon: number }> } & Record<string, unknown>;
|
||||
chart_modules_shadbala: Record<string, unknown>;
|
||||
ashtakavarga: Record<string, unknown>;
|
||||
bhava_bala: Record<string, unknown>;
|
||||
chart_points: Record<string, unknown>;
|
||||
};
|
||||
const board = readFileSync(new URL("../src/components/chart-board/chart-board.css", import.meta.url), "utf8");
|
||||
|
||||
const SEVEN = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"];
|
||||
const KARAKA_KEYS = ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"];
|
||||
/**
|
||||
* The fixture's chart_points predates the karaka block. Build it the way
|
||||
* scripts/chart_view_points/api.py does (jaimini.calc_chara_karaka_7: highest
|
||||
* in-sign degree first); tests/test_chart_view_points_api.py checks the producer.
|
||||
*/
|
||||
const ranked = [...SEVEN].sort((a, b) => (insight.chart.planets[b]!.lon % 30) - (insight.chart.planets[a]!.lon % 30));
|
||||
const charaKarakas = {
|
||||
status: "computed",
|
||||
scheme: "chara_7",
|
||||
rows: ranked.map((planet, index) => ({ key: KARAKA_KEYS[index], planet, rank: index + 1 })),
|
||||
};
|
||||
const chartPoints = { ...insight.chart_points, chara_karakas: charaKarakas };
|
||||
|
||||
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(): Promise<ChartViewOk> {
|
||||
const result = await assembleChartView({
|
||||
userId: "user-1",
|
||||
profile,
|
||||
asOf: "2026-10-27",
|
||||
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/ashtakavarga") return { status: "ok", payload: insight.ashtakavarga };
|
||||
if (path === "/api/bhava_bala") return { status: "ok", payload: insight.bhava_bala };
|
||||
if (path === "/api/chart_points") return { status: "ok", payload: chartPoints };
|
||||
return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 };
|
||||
},
|
||||
});
|
||||
assert.equal(result.body.status, "ok");
|
||||
return result.body as ChartViewOk;
|
||||
}
|
||||
|
||||
function fakeBoard(view: ChartViewOk): ChartBoardState {
|
||||
const model = natalBoard(view);
|
||||
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", () => {
|
||||
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);
|
||||
const short = mapPoints({ ...insight.chart_points, chara_karakas: { ...charaKarakas, rows: charaKarakas.rows.slice(0, 6) } });
|
||||
assert.equal(short.status === "ok" && short.karakas, null);
|
||||
});
|
||||
|
||||
test("星力 writes each body's shadbala share under it, green when it meets the minimum", async () => {
|
||||
const view = await okView();
|
||||
const model = natalBoard(view);
|
||||
const focus = boardFocusFor(view, model, "strength", DEFAULT_DATA_PICKS)!;
|
||||
for (const row of view.shadbala!) {
|
||||
const mark = focus.marks.get(planetGlyphKey(row.name)!)!;
|
||||
assert.equal(mark.text, `${Math.round(row.percent)}%`);
|
||||
assert.equal(mark.tone, row.percent >= 100 ? "good" : "warn");
|
||||
}
|
||||
assert.ok(focus.dimmed.has("rahu") && focus.dimmed.has("ketu"), "nodes have no shadbala and go faint");
|
||||
});
|
||||
|
||||
test("大运 marks the current Vimshottari lords, tints the mahadasha lord's houses and links the levels", async () => {
|
||||
const view = await okView();
|
||||
const model = natalBoard(view);
|
||||
const day = view.dasha.asOf;
|
||||
const now = vimshottariAt(view, day)!;
|
||||
const pratyantar = vimshottariSplit(now.antar!.lord, now.antar!.start, now.antar!.end).find((span) => span.start <= day && day < span.end)!;
|
||||
const focus = boardFocusFor(view, model, "dasha", DEFAULT_DATA_PICKS)!;
|
||||
const maha = planetGlyphKey(now.maha.lord)!;
|
||||
assert.equal(focus.marks.get(maha)?.text, "大运");
|
||||
for (const [lord, label] of [[now.antar!.lord, "分运"], [pratyantar.lord, "小运"]] as const) {
|
||||
const key = planetGlyphKey(lord)!;
|
||||
if (key !== maha) assert.ok(focus.marks.has(key), label);
|
||||
}
|
||||
assert.deepEqual([...focus.houses].sort(), lordedHouses(model, maha).sort());
|
||||
for (const link of focus.links) assert.notEqual(bodyHouse(model, link.from), bodyHouse(model, link.to));
|
||||
|
||||
const picked = boardFocusFor(view, model, "dasha", {
|
||||
...DEFAULT_DATA_PICKS,
|
||||
dashaPick: { start: now.maha.start, level: "大运", lord: "Jupiter", sign: null },
|
||||
})!;
|
||||
assert.deepEqual([...picked.lit], ["jupiter"]);
|
||||
assert.equal(picked.marks.get("jupiter")?.text, "运主");
|
||||
assert.deepEqual([...picked.houses].sort(), lordedHouses(model, "jupiter").sort());
|
||||
});
|
||||
|
||||
test("Chara tints the period's sign, and 敌友 paints every body by its relation to the picked one", async () => {
|
||||
const view = await okView();
|
||||
const model = natalBoard(view);
|
||||
const sign = model.houses[4]!.sign;
|
||||
const chara = boardFocusFor(view, model, "dasha", {
|
||||
...DEFAULT_DATA_PICKS,
|
||||
dashaSystem: "chara",
|
||||
dashaPick: { start: "2000-01-01", level: "大运", lord: null, sign: ["Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces"][sign]! },
|
||||
})!;
|
||||
assert.deepEqual([...chara.houses], [5]);
|
||||
|
||||
const picks = { ...DEFAULT_DATA_PICKS, relationOf: "sun" as const, relationTo: "saturn" as const };
|
||||
const relation = boardFocusFor(view, model, "relation", picks)!;
|
||||
assert.ok(relation.lit.has("sun"));
|
||||
for (const key of ["moon", "mars", "mercury", "jupiter", "venus", "saturn"] as const) {
|
||||
const value = relationValue(view, "sun", key, "compound")!;
|
||||
const mark = relation.marks.get(key)!;
|
||||
assert.equal(mark.tone, value > 0 ? "good" : value < 0 ? "bad" : "plain");
|
||||
assert.ok(mark.paint);
|
||||
}
|
||||
const saturn = relationValue(view, "sun", "saturn", "compound")!;
|
||||
if (bodyHouse(model, "sun") !== bodyHouse(model, "saturn")) {
|
||||
assert.deepEqual(relation.links, [{ from: "sun", to: "saturn", tone: saturn > 0 ? "good" : saturn < 0 ? "bad" : "plain" }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("瑜伽 keeps the yoga's bodies lit, tints their houses and fades the rest", async () => {
|
||||
const base = await okView();
|
||||
const view: ChartViewOk = { ...base, yogas: { formed: [{ id: "budha-aditya", label: "Budha-Aditya", planets: ["Sun", "Mercury"], reductions: [] }], notFormed: [] } };
|
||||
const model = natalBoard(view);
|
||||
const focus = boardFocusFor(view, model, "yoga", DEFAULT_DATA_PICKS)!;
|
||||
assert.deepEqual([...focus.lit].sort(), ["mercury", "sun"]);
|
||||
assert.deepEqual([...focus.houses].sort(), [...new Set([bodyHouse(model, "sun"), bodyHouse(model, "mercury")])].sort());
|
||||
assert.ok(!focus.dimmed.has("sun") && focus.dimmed.has("moon"));
|
||||
assert.equal(boardFocusFor(view, model, "yoga", { ...DEFAULT_DATA_PICKS, yogaPick: null }), null);
|
||||
assert.equal(boardFocusFor(view, model, "planets", DEFAULT_DATA_PICKS), null);
|
||||
});
|
||||
|
||||
test("the board draws the focus: tinted houses, a word under a body, a dashed link", async () => {
|
||||
const view = await okView();
|
||||
const model = natalBoard(view);
|
||||
const sun = bodyHouse(model, "sun")!;
|
||||
const other = (["moon", "mars", "jupiter", "venus", "saturn"] as const).find((key) => bodyHouse(model, key) !== sun)!;
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartBoardSvg, {
|
||||
model,
|
||||
style: "north",
|
||||
selection: null,
|
||||
onSelect: () => {},
|
||||
ariaLabel: "本命盘",
|
||||
focus: {
|
||||
lit: new Set<PlanetGlyphKey>(["sun"]),
|
||||
dimmed: new Set<PlanetGlyphKey>(["ketu"]),
|
||||
marks: new Map<PlanetGlyphKey, FocusMark>([["sun", { text: "大运", tone: "action", paint: true }]]),
|
||||
houses: new Set([sun]),
|
||||
links: [{ from: "sun", to: other, tone: "good" }],
|
||||
} satisfies BoardFocus,
|
||||
}));
|
||||
assert.match(markup, /chart-board-cell is-tinted/);
|
||||
assert.match(markup, /chart-board-body is-paint-action is-lit/);
|
||||
assert.match(markup, /class="chart-board-focus-mark is-action"[^>]*>大运</);
|
||||
assert.match(markup, /chart-board-focus-link is-good/);
|
||||
assert.match(markup, /chart-board-body[^"]*is-dim/);
|
||||
});
|
||||
|
||||
test("盘面数据 has nine tabs, and 行星 lists the ascendant first with nakshatra lord, pada and karaka", async () => {
|
||||
const view = await okView();
|
||||
assert.deepEqual(CHART_DATA_TABS.map(([, label]) => label), ["行星", "虚星", "特殊点", "映射", "八分点", "星力", "大运", "敌友", "瑜伽"]);
|
||||
const markup = renderToStaticMarkup(React.createElement(ChartDataCard, {
|
||||
view,
|
||||
board: fakeBoard(view),
|
||||
type: "D1",
|
||||
tab: "planets",
|
||||
onTab: () => {},
|
||||
picks: DEFAULT_DATA_PICKS,
|
||||
onPicks: () => {},
|
||||
}));
|
||||
for (const label of ["盘面数据", "月宿 · 足", "宿主", "旺陷", "指标"]) 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<`));
|
||||
assert.doesNotMatch(markup, /点一行|在盘上定位/);
|
||||
});
|
||||
|
||||
test("cards keep fixed heights, the data card fills the board's row, and wide tables scroll instead of wrapping", () => {
|
||||
assert.match(cssDeclarations(".chart-data-slot > .chart-data-card", board), /position: absolute/);
|
||||
assert.match(cssDeclarations(".chart-data-scroll", board), /overflow-y: auto/);
|
||||
assert.match(cssDeclarations(".chart-side-stack", board), /height: 440px/);
|
||||
assert.match(cssDeclarations(".chart-deep", board), /height: 640px/);
|
||||
assert.match(cssDeclarations(".chart-board-caption", board), /flex-wrap: nowrap/);
|
||||
assert.match(board, /\.chart-data-card \.chart-graha-table td[^{]*\{ white-space: nowrap; \}/);
|
||||
assert.match(board, /@media \(max-width: 1023px\) \{\n \.chart-workspace-grid \{[^}]*\}\n \.chart-data-slot \{ min-height: 0; height: 560px; \}/);
|
||||
});
|
||||
|
||||
test("a data tab moves the board layer, and a board layer opens its data tab", async () => {
|
||||
const view = await okView();
|
||||
const calls: string[] = [];
|
||||
const h = createClientLifecycleHarness();
|
||||
const tabs = (label: string) => h.elements().filter((node) => node.tagName === "BUTTON" && node.text === label);
|
||||
try {
|
||||
await h.render(React.createElement(SidebarProvider, null, React.createElement(ChartPageView, {
|
||||
view,
|
||||
onNeedLayer: (layer: string) => { calls.push(layer); },
|
||||
})));
|
||||
// Board layer row first, then the data card's tabs.
|
||||
const [layerAv, dataAv] = tabs("八分点");
|
||||
assert.ok(layerAv && dataAv);
|
||||
await h.event(dataAv);
|
||||
assert.ok(calls.includes("ashtakavarga"));
|
||||
assert.equal(tabs("八分点")[0]!.getAttribute("aria-selected"), "true", "the board shows 八分点");
|
||||
await h.event(tabs("虚星")[0]!);
|
||||
assert.equal(tabs("虚星")[1]!.getAttribute("aria-selected"), "true", "the data card opens 虚星");
|
||||
await h.event(tabs("基础星盘")[0]!);
|
||||
assert.equal(tabs("行星")[0]!.getAttribute("aria-selected"), "true", "leaving a point layer goes back to 行星");
|
||||
await h.event(tabs("星力")[0]!);
|
||||
assert.ok(h.elements().some((node) => node.getAttribute("class")?.includes("chart-board-focus-mark")), "星力 marks the board");
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
});
|
||||
@@ -227,8 +227,14 @@ test("the four tabs render and can each be selected", async () => {
|
||||
// 旧 initialTab 仍可用:basics → 排盘底册,dasha → 时运流年,western → 西洋盘。
|
||||
assert.match(markup, />印度盘</);
|
||||
assert.match(markup, />西洋盘</);
|
||||
// 原值:深读四页签「时运流年 / 能量图谱 / 命性格局 / 排盘底册」
|
||||
// 新值:深读三页签「时运流年 / 能量图谱 / 出生资料与口径」;表格和格局在盘旁的「盘面数据」九个页签里
|
||||
// 原因:2026-10-30 产品确认盘面数据卡原型:表格搬到盘旁并与盘联动,深读不再重复放表
|
||||
if (tab.id !== "western") {
|
||||
for (const label of ["时运流年", "能量图谱", "命性格局", "排盘底册"]) assert.ok(markup.includes(label), label);
|
||||
for (const label of ["时运流年", "能量图谱", "出生资料与口径", "盘面数据", "行星", "虚星", "特殊点", "映射", "八分点", "星力", "大运", "敌友", "瑜伽"]) {
|
||||
assert.ok(markup.includes(label), label);
|
||||
}
|
||||
assert.doesNotMatch(markup, /命性格局|排盘底册|八宫点/);
|
||||
}
|
||||
// 原值: 必须含「七政四余」Tab
|
||||
// 新值: 不得含「七政四余」
|
||||
@@ -237,15 +243,16 @@ test("the four tabs render and can each be selected", async () => {
|
||||
assert.doesNotMatch(markup, /正在加载|骨架|spinner/i);
|
||||
if (tab.id === "vedic") assert.match(markup, /D1/);
|
||||
if (tab.id === "basics") {
|
||||
// 原值:基础信息页含「本命」与「D9」
|
||||
// 新值:排盘底册的行星名册含「D9」一列和「宫主流向」
|
||||
// 原因:基础信息并进「排盘底册」(issue #114 M7),行星卡改成名册表
|
||||
assert.match(markup, /数据表/);
|
||||
assert.match(markup, /D9/);
|
||||
assert.match(markup, /宫主流向/);
|
||||
// 原值:排盘底册的行星名册含「D9」一列和「宫主流向」
|
||||
// 新值:深读「出生资料与口径」只留出生资料、计算口径和出生历法;行星表在「盘面数据 · 行星」
|
||||
// 原因:2026-10-30 盘面数据卡原型,表格不在深读里重复
|
||||
assert.match(markup, /出生资料与计算口径/);
|
||||
assert.match(markup, /月宿 · 足/);
|
||||
}
|
||||
if (tab.id === "dasha") {
|
||||
assert.match(markup, /Vimshottari/);
|
||||
// 原值:/Vimshottari/(右栏「Vimshottari 大运」条);新值:时运流年的「当前运期」
|
||||
// 原因:2026-10-30 大运条并进「盘面数据 · 大运」,默认页签是行星
|
||||
assert.match(markup, /当前运期/);
|
||||
assert.match(markup, /Chara Dasha(kn_rao 变体)/);
|
||||
assert.match(markup, /只有两条同时指向同一段时间才算证据/);
|
||||
}
|
||||
@@ -487,7 +494,7 @@ test("western and chara failures are terminal copy rather than waiting", async (
|
||||
for (const [tab, layer] of [["western", "western"], ["dasha", "chara"]] as const) {
|
||||
const markup = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: tab, layerFailures: new Map([[layer, "engine_busy" as const]]) }));
|
||||
assert.ok(markup.includes(CHART_VIEW_COPY.busy)); assert.doesNotMatch(markup, /data-skeleton|这一栏还没拿到/);
|
||||
if (tab === "dasha") assert.match(markup, /Vimshottari/);
|
||||
if (tab === "dasha") assert.match(markup, /当前运期/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -694,11 +701,13 @@ test("the time tab asks for chara with its own layers, and Yogini is a separate
|
||||
view,
|
||||
onNeedLayer: (layer: string) => { calls.push(layer); },
|
||||
})));
|
||||
assert.deepEqual(calls, ["ashtakavarga", "chara", "transit"]);
|
||||
// 原值:["ashtakavarga", "chara", "transit"];新值:先有 "points"
|
||||
// 原因:2026-10-30 盘面数据卡默认「行星」页签,Karaka 指标列读 points 层
|
||||
assert.deepEqual(calls, ["points", "ashtakavarga", "chara", "transit"]);
|
||||
assert.match(h.container.text, /Chara Dasha(kn_rao 变体)/);
|
||||
assert.match(h.container.text, /Vimshottari/);
|
||||
assert.match(h.container.text, /当前运期/);
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.deepEqual(calls, ["ashtakavarga", "chara", "transit", "yogini"]);
|
||||
assert.deepEqual(calls, ["points", "ashtakavarga", "chara", "transit", "yogini"]);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
@@ -718,12 +727,12 @@ test("a yogini failure stays in the right column, and clicking Yogini again asks
|
||||
// 新值:时运流年默认展开,calls 以它出现时的三层开头
|
||||
// 原因:issue #114 Q2 A,大运页签并进时运流年
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.match(h.container.text, /Vimshottari/);
|
||||
assert.match(h.container.text, /当前运期/);
|
||||
assert.match(h.container.text, new RegExp(CHART_VIEW_COPY.busy));
|
||||
assert.doesNotMatch(h.container.text, /小运未经外部软件核对。/);
|
||||
assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.deepEqual(calls, ["ashtakavarga", "chara", "transit", "yogini", "yogini"]);
|
||||
assert.deepEqual(calls, ["points", "ashtakavarga", "chara", "transit", "yogini", "yogini"]);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
@@ -740,7 +749,7 @@ test("yogini pending reuses the orbit sentence and does not draw a skeleton", as
|
||||
// 原值:先点「大运」页签;新值:不点(时运流年默认展开);原因:issue #114 Q2 A
|
||||
await h.event(buttonByLabel(h, "Yogini"));
|
||||
assert.match(h.container.text, /这一栏还没拿到/);
|
||||
assert.match(h.container.text, /Vimshottari/);
|
||||
assert.match(h.container.text, /当前运期/);
|
||||
assert.equal(h.elements().some((node) => node.hasAttribute("data-skeleton")), false);
|
||||
} finally {
|
||||
await h.close();
|
||||
|
||||
@@ -9,6 +9,8 @@ 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
|
||||
|
||||
Each block fails on its own (``status: blocked`` + reason); one failure never
|
||||
fails the response.
|
||||
@@ -81,6 +83,11 @@ 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")
|
||||
ARUDHA_ORDER = ("A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "UL")
|
||||
|
||||
|
||||
@@ -163,6 +170,25 @@ 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
|
||||
|
||||
degrees = {name: lon % 30 for name in SEVEN if (lon := _longitude(planets.get(name))) is not None}
|
||||
if len(degrees) < len(SEVEN):
|
||||
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}
|
||||
|
||||
def friendship() -> dict[str, Any]:
|
||||
from jyotish_engine import _build_planetary_friendship_snapshot
|
||||
|
||||
@@ -177,4 +203,5 @@ 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),
|
||||
}
|
||||
|
||||
@@ -75,6 +75,28 @@ def test_friendship_compound_is_natural_plus_temporary():
|
||||
assert target in sun["natural"]["enemies"] and target in sun["temporary"]["enemies"]
|
||||
|
||||
|
||||
def test_chara_karakas_rank_the_seven_by_in_sign_degree():
|
||||
result = compute_chart_points(BODY)["chara_karakas"]
|
||||
assert result["status"] == "computed"
|
||||
assert result["scheme"] == "chara_7"
|
||||
rows = result["rows"]
|
||||
assert [row["key"] for row in rows] == ["AK", "AmK", "BK", "MK", "PK", "GK", "DK"]
|
||||
planets = BODY["planets"]
|
||||
expected = sorted(
|
||||
("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"),
|
||||
key=lambda name: planets[name]["lon"] % 30,
|
||||
reverse=True,
|
||||
)
|
||||
assert [row["planet"] for row in rows] == expected
|
||||
|
||||
|
||||
def test_chara_karakas_block_on_a_tie():
|
||||
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"}
|
||||
|
||||
|
||||
def test_one_failing_block_does_not_fail_the_response(monkeypatch):
|
||||
import gulika
|
||||
|
||||
|
||||
Reference in New Issue
Block a user