diff --git a/CHANGELOG.md b/CHANGELOG.md index c3dd31de..b4b68824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ # 印度占星 Skill 更新日志 -## 2026-10-30 — 星盘图层「无图层」改名「基础星盘」 +## 2026-10-30 — 星盘页:盘旁「盘面数据」卡,表格和盘联动 -- 星盘卡图层一排的第一项改叫「基础星盘」,意思不变:只看本命 D1 盘面,不叠八宫点、宫力、行运等。 -- 只改名称,不改计算与默认选项;Skill 版本不变。 +- 盘的右边(手机上是盘的下面)新增「盘面数据」卡,九个页签:行星、虚星、特殊点、映射、八分点、星力、大运、敌友、瑜伽。原来散在「九曜」「Vimshottari 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。 +- 行星表多了上升一行、宿主、足,以及七星 Chara Karaka「指标」一列(AK~DK,由引擎排出;两颗星同度时这一列留空)。 +- 切到虚星、特殊点、映射、八分点,盘上自动换成同名图层;反过来在盘上换图层,数据卡也跟着换。 +- 切到星力、大运、敌友、瑜伽,盘上直接标出来:星力在每颗星下写达标百分比;大运标出当前大运、分运、小运的主星和大运主星管的宫;敌友按关系给其余星着色;瑜伽只亮涉及的星和宫并连线。点表里的一段大运、一格敌友或一条瑜伽,盘上跟着换。 +- 每张卡片固定高度,内容多了在卡里滚动,切页签时页面不再上下跳。表格不换行,放不下时左右滑动,鼠标可以按住拖。 +- 去掉提示类小字(例如「点一行,在盘上定位」、各卡标题旁的说明、图层下面的说明句)。 +- 图层第一项「无图层」改叫「基础星盘」;「八宫点」统一叫「八分点」。 +- 深读剩三个页签:时运流年、能量图谱、出生资料与口径。 +- 特殊点下的敏感点、Saham、Goel 特殊点三组和「功能吉凶」一列还没做:接口还没有这些数据,功能吉凶的口径待占星师确认。 +- Skill 版本不变。 ## 2026-10-29 — D60 分盘的星座算法改对 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 86e1e939..bf3e9ff2 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -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`,以下土星色;宫力写引擎宫力数,高于本盘十二宫均值一成偏暖、低一成偏冷(北印度把这个数接在星座名后面);行运把观测日的行运星用土星色小字写在对应星座。图层下一行灰字说明参考线。 diff --git a/frontend/src/components/chart-board/chart-board-card.tsx b/frontend/src/components/chart-board/chart-board-card.tsx index 020baebb..7a89b2eb 100644 --- a/frontend/src/components/chart-board/chart-board-card.tsx +++ b/frontend/src/components/chart-board/chart-board-card.tsx @@ -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"; @@ -35,7 +36,7 @@ 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 = [ ["none", "基础星盘"], - ["sav", "八宫点"], + ["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 ( {style === "north" ? "北印度盘" : "南印度盘"} - {style === "north" ? "宫位固定" : "星座固定"} · 整宫制 @@ -215,7 +215,6 @@ export function ChartBoardCard({ {caption.kicker} {caption.title} {caption.detail ? {caption.detail} : null} - {"note" in caption && caption.note ? · {caption.note} : null} {"chips" in caption && caption.chips?.length ? ( {caption.chips.map((chip) => {chip})} @@ -242,6 +241,7 @@ export function ChartBoardCard({ pointMarks={pointMarks} onPointSelect={onPointSelect} dignity={natal ? dignityTones(view) : undefined} + focus={natal ? focus : null} /> ) : !pending && failure ? ( @@ -259,18 +259,9 @@ export function ChartBoardCard({ {natal && activeLayer !== "none" && layerFailure && !layerPending ? (

{chartLayerFailureCopy(layerFailure)}

) : null} - {natal && activeLayer === "sav" && !layerFailure ? ( -

八宫点:{SAV_REFERENCE} 点及以上偏暖,以下偏冷。{SAV_REFERENCE} 是传统参考线,不是本盘均值。

- ) : null} - {natal && activeLayer === "bhava" && mean !== null ? ( -

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

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

{chartLayerFailureCopy(pointsFailure)}

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

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

- ) : null} {natal && activeLayer === "transit" && !transit && !transitPending ? (

这一天的行运暂时没算出来,稍后再试。

) : null} diff --git a/frontend/src/components/chart-board/chart-board-svg.tsx b/frontend/src/components/chart-board/chart-board-svg.tsx index bb12328d..d5a56baf 100644 --- a/frontend/src/components/chart-board/chart-board-svg.tsx +++ b/frontend/src/components/chart-board/chart-board-svg.tsx @@ -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; model: BoardModel; @@ -177,6 +181,14 @@ export function ChartBoardSvg({ const marksBySign = new Map(); for (const mark of pointMarks) marksBySign.set(mark.sign, [...(marksBySign.get(mark.sign) ?? []), mark]); const selectedPoint = selection?.kind === "point" ? selection.key : null; + const layouts = new Map(cells.map((cell) => [cell.house, bodyLayout(model.houses[cell.house - 1]!.bodies.length, cell, style)])); + const bodyPoint = new Map(); + 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 ( { 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 ; })() : null} + {focus && focus.links.length > 0 && !hasSelection ? ( + + ) : 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 ( {`${Math.floor(body.degree)}°${body.retrograde && !node ? " R" : ""}`} + {mark ? ( + {mark.text} + ) : null} ); })} diff --git a/frontend/src/components/chart-board/chart-board.css b/frontend/src/components/chart-board/chart-board.css index 9307256d..592b1f9a 100644 --- a/frontend/src/components/chart-board/chart-board.css +++ b/frontend/src/components/chart-board/chart-board.css @@ -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; } diff --git a/frontend/src/components/chart-board/chart-data-card.tsx b/frontend/src/components/chart-board/chart-data-card.tsx new file mode 100644 index 00000000..6947100f --- /dev/null +++ b/frontend/src/components/chart-board/chart-data-card.tsx @@ -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
{children}
; +} + +function Waiting({ pending, failure }: { pending: boolean; failure?: ChartViewFailureStatus }) { + return !pending && failure + ?

{chartLayerFailureCopy(failure)}

+ :

; +} + +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 ( + + + + + + + + + + + + + + + {asc ? ( + + + + + + + + + + ) : 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 ( + key && board.select(on ? null : { kind: "body", key })} + > + + + + + + + + + ); + })} + +
星体位置宫月宿 · 足宿主旺陷指标
{asc.label}{asc.signLabel} {formatDegreeMinutes(asc.degreeInSign)}1{asc.nakshatra ? `${asc.nakshatra}${asc.pada ? ` · ${asc.pada}` : ""}` : "—"}{asc.nakshatraLord ?? "—"}——
{row.label} + {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} + {row.retrograde && !node ? 逆 : null} + {row.house}{row.nakshatra} · {row.pada}{row.nakshatraLord}= 6 ? "chart-graha-good" : undefined}> + {dignity?.label ?? "—"} + {node ? "—" : karakaOf(row.name)}
+
+ ); +} + +function PointTable({ view, board, group }: { view: ChartViewOk; board: ChartBoardState; group: PointGroup }) { + const rows = pointsOf(view, group); + if (!rows) return

这张表暂时没算出来。

; + const picked = board.selection?.kind === "point" ? board.selection.key : null; + const arudha = group === "arudha"; + return ( + + + + + + + + {arudha ? : <>} + + + + + {rows.map((point) => { + const on = picked === point.key; + const pick = () => board.pickPoint(on ? null : point); + return ( + + + + + {arudha + ? + : <>} + + + ); + })} + +
名称{arudha ? "星座" : "黄道经度"}宫位来自月宿宿主足传统含义
+ + {point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""}{point.house}宫第 {point.sourceHouse ?? "—"} 宫 · 主 {point.sourceLord ?? "—"}{point.nakshatra ?? "—"}{point.nakshatraLord ?? "—"}{point.pada ?? "—"}{point.note}
+
+ ); +} + +function 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 ( + <> +
+ value && setBy(value as "house" | "sign")}> + + 按宫位 + 按星座 + + + value && setReading(value as "raw" | "trikona" | "sodhita")}> + + 原始 + 三角削减 + 两步削减 + + +
+
+ + + + + {columns.map((sign, index) => ( + + ))} + + + + + {reading === "raw" && av.lagnaBav ? ( + + + {columns.map((sign) => )} + + + ) : null} + {av.bav.map((row) => { + const values = row[reading]; + const key = planetGlyphKey(row.name); + return ( + + + {columns.map((sign) => { + const value = values[sign] ?? 0; + return ; + })} + + + ); + })} + + + {columns.map((sign) => { + const total = av.bav.reduce((sum, row) => sum + (row[reading][sign] ?? 0), 0); + return ; + })} + + + +
星体{by === "house" ? <>{index + 1}宫
: null}{signZh(SIGN_ORDER[sign] ?? "")}
计
升{av.lagnaBav![sign] ?? 0}{av.lagnaBav.reduce((sum, value) => sum + value, 0)}
{key ? PLANET_GLYPH_ZH[key] : row.label}= 6 ? "is-high" : value >= 5 ? "is-good" : value <= 2 ? "is-low" : undefined}>{value}{values.reduce((sum, value) => sum + value, 0)}
总= SAV_REFERENCE ? "is-good" : "is-low") : undefined}>{total}{av.bav.reduce((sum, row) => sum + row[reading].reduce((a, b) => a + b, 0), 0)}
+
+ + ); +} + +function StrengthTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) { + const [part, setPart] = useState<"total" | "sthana" | "kala" | "other">("total"); + const rows = view.shadbala ?? []; + if (rows.length === 0) return

这张盘没有返回六力。

; + const current = board.selection?.kind === "body" ? board.selection.key : null; + const head = part === "total" ? ["总分", "达标分", "达标鲁帕", "满足率", "差额"] + : part === "sthana" ? [...STHANA_PARTS.map(([, label]) => label), "小计"] + : part === "kala" ? [...KALA_PARTS.map(([, label]) => label), "小计"] + : ["方位力", "运动力", "自然力", "视相力"]; + return ( + <> +
+ value && setPart(value as typeof part)}> + + 总表 + 位置力 + 时间力 + 其余四力 + + +
+ + + {head.map((label) => )} + + {rows.map((row) => { + const key = planetGlyphKey(row.name); + const on = key !== null && current === key; + const total = row.rupas * 60; + const required = row.required * 60; + let cells: ReactNode; + if (part === "total") { + cells = ( + <> + + + + + + + ); + } else if (part === "other") { + cells = row.parts + ? ["dig", "chesta", "naisargika", "drik"].map((id) => ) + : ; + } else { + const list = part === "sthana" ? STHANA_PARTS : KALA_PARTS; + const values = part === "sthana" ? row.sthanaParts : row.kalaParts; + cells = values + ? <> + {list.map(([id]) => )} + + + : ; + } + return ( + key && board.select(on ? null : { kind: "body", key })}> + + {cells} + + ); + })} + +
行星{label}
{total.toFixed(1)}{required.toFixed(0)}{row.required.toFixed(2)}{row.percent.toFixed(1)}%{STRENGTH_LEVEL_ZH[row.level]}= required ? "chart-graha-good" : "chart-graha-bad"}`}>{total >= required ? "+" : ""}{(total - required).toFixed(1)}{row.parts![id as "dig"].toFixed(1)}—{(values as Record)[id]!.toFixed(1)}{(part === "sthana" ? row.parts?.sthana : row.parts?.kala)?.toFixed(1) ?? "—"}—
{row.label}
+
+ + ); +} + +const DASHA_SYSTEMS: ReadonlyArray = [ + ["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) => void; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; + 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 ( + <> +
+ { + if (!value) return; + onPicks({ dashaSystem: value as DashaSystem, dashaPick: null }); + if (value !== "vimshottari") onNeedLayer?.(value as ChartViewLayer); + }} + > + + {DASHA_SYSTEMS.map(([id, label]) => {label})} + + +
+ {track.periods.length === 0 || pending || failure ? : ( + <> +
+ {now.map((row) => ( + + ))} +
+ + + + + {track.periods.map((period) => { + const row: DashaRow = { level: "大运", span: period, sign: period.sign }; + const on = isPicked(row); + const isNow = maha?.start === period.start; + return ( + pick(row)} + > + + + + + ); + })} + +
大运起止
{period.label}{period.start}{period.end}
+
+ + )} + + ); +} + +const RELATION_MODES: ReadonlyArray = [ + ["natural", "天生"], + ["temporary", "临时"], + ["compound", "综合"], +]; + +function RelationTable({ view, picks, onPicks }: { + view: ChartViewOk; + picks: ChartDataPicks; + onPicks: (next: Partial) => void; +}) { + const friendship = view.points?.status === "ok" ? view.points.friendship : null; + if (!friendship) return

这张表暂时没算出来。

; + const keys = friendship.rows.map((row) => planetGlyphKey(row.name)).filter((key): key is PlanetGlyphKey => key !== null); + const mode = picks.relationMode; + return ( + <> +
+ value && onPicks({ relationMode: value as RelationMode })}> + + {RELATION_MODES.map(([id, label]) => {label})} + + +
+ + + + {keys.map((key) => )} + + + {keys.map((from) => { + const rowOn = picks.relationOf === from; + return ( + + + {keys.map((to) => { + if (to === from) return ; + const value = relationValue(view, from, to, mode); + if (value === null) return ; + const on = rowOn && picks.relationTo === to; + return ( + + ); + })} + + ); + })} + +
星体{PLANET_GLYPH_ZH[key]}
+ + —— 0 ? "chart-graha-good" : value < 0 ? "chart-graha-bad" : ""}${on ? " is-picked" : ""}`.trim() || undefined}> + +
+
+ + ); +} + +function YogaList({ view, picks, onPicks }: { + view: ChartViewOk; + picks: ChartDataPicks; + onPicks: (next: Partial) => void; +}) { + const yogas = view.yogas; + if (!yogas) return

这张盘没有返回格局。

; + return ( + <> + {yogas.formed.length > 0 ? ( +
+ {yogas.formed.map((yoga, index) => { + const on = picks.yogaPick === index; + return ( + + ); + })} +
+ ) :

本盘没有成立的经典格局。

} + {yogas.notFormed.length > 0 ? ( + + }> + 未满足 {yogas.notFormed.length} 条 + + +
    + {yogas.notFormed.map((yoga) =>
  • {yoga.label}
  • )} +
+
+
+ ) : 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) => void; + pendingLayers?: ReadonlySet; + layerFailures?: ReadonlyMap; + 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" ? : ; + } else if (tab === "upagraha" || tab === "special" || tab === "arudha") { + body = pointsReady ? : ; + } else if (tab === "av") { + body = avReady ? : ; + } else if (tab === "strength") { + body = ; + } else if (tab === "dasha") { + body = ; + } else if (tab === "relation") { + body = pointsReady ? : ; + } else { + body = ; + } + return ( + + + 盘面数据 + + + value && onTab(value as ChartDataTab)}> + + {CHART_DATA_TABS.map(([id, label]) => {label})} + + +
{body}
+
+
+ ); +} diff --git a/frontend/src/components/chart-board/chart-deep-reading.tsx b/frontend/src/components/chart-board/chart-deep-reading.tsx index 8b6db54a..4de32e28 100644 --- a/frontend/src/components/chart-board/chart-deep-reading.tsx +++ b/frontend/src/components/chart-board/chart-deep-reading.tsx @@ -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 (
-
+
-
+
{now ? ( <> @@ -243,7 +235,7 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai 星座 上升起 月亮起 - 八宫点 + 八分点 @@ -276,12 +268,11 @@ function TimeTab({ view, observe, onObserve, transit, transitPending, transitFai

)}
- 行运是这一天正午的位置,不代表事件一定发生。
-
+
("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 (
-
+
{rows.length > 0 ? (
{rows.map((row) => { @@ -385,11 +372,10 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
{SHADBALA_PARTS.map(([id, label]) => {label})}
-

强 = 达标 120% 以上,中 = 90%~120%,弱 = 不到 90%。

) :

这张盘没有返回六力。

}
-
+
{av?.bhavaBala && mean !== null ? ( <>
@@ -414,7 +400,6 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: {
{av.bhavaBala.map((house) => {house.house})}
- 单位 {av.bhavaBalaUnit};高于均值一成记强,低一成记弱。
@@ -422,332 +407,13 @@ function EnergyTab({ view, board, pending, failure, onNeedLayer }: { :

}
-
- {av ? ( - <> -
- value && setBy(value as "house" | "sign")}> - - 按宫位 - 按星座 - - - value && setReading(value as "raw" | "trikona" | "sodhita")}> - - 原始 - 三角削减 - 两步削减 - - -
-
- - - - - {columns.map((sign, index) => ( - - ))} - - - - - {reading === "raw" && av.lagnaBav ? ( - - - {columns.map((sign) => )} - - - ) : null} - {av.bav.map((row) => { - const values = row[reading]; - const key = planetGlyphKey(row.name); - return ( - - - {columns.map((sign) => { - const value = values[sign] ?? 0; - return ; - })} - - - ); - })} - - - {columns.map((sign) => { - const total = av.bav.reduce((sum, row) => sum + (row[reading][sign] ?? 0), 0); - return ; - })} - - - -
星体{by === "house" ? <>{index + 1}宫
: null}{signZh(SIGN_ORDER[sign] ?? "")}
计
升{av.lagnaBav![sign] ?? 0}{av.lagnaBav.reduce((sum, value) => sum + value, 0)}
{key ? PLANET_GLYPH_ZH[key] : row.label}= 6 ? "is-high" : value >= 5 ? "is-good" : value <= 2 ? "is-low" : undefined}>{value}{values.reduce((sum, value) => sum + value, 0)}
总= SAV_REFERENCE ? "is-good" : "is-low") : undefined}>{total}{av.bav.reduce((sum, row) => sum + row[reading].reduce((a, b) => a + b, 0), 0)}
-
-
- {SAV_REFERENCE} 是传统参考线,不是本盘均值。「升」是上升自己的一行,不计入合计。 - -
- - ) : !pending && failure ?

{chartLayerFailureCopy(failure)}

- :

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

这张盘没有返回格局。

; - return ( -
-
- {yogas.formed.length > 0 ? ( -
- {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 ( - - ); - })} -
- ) :

本盘没有成立的经典格局。

} - {yogas.notFormed.length > 0 ? ( - - }> - 未满足 {yogas.notFormed.length} 条 - - -
    - {yogas.notFormed.map((yoga) =>
  • {yoga.label}
  • )} -
-
-
- ) : null} -

传统格局,本站未验证,只作参考。

-
-
- ); -} - -type BookTable = "planets" | "upagraha" | "special" | "arudha" | "strength" | "relation"; - -const BOOK_TABLES: ReadonlyArray = [ - ["planets", "行星"], - ["upagraha", "虚星"], - ["special", "特殊点"], - ["arudha", "映射"], - ["strength", "星力明细"], - ["relation", "敌友"], -]; - -const STHANA_PARTS = [["uccha", "高位力"], ["saptavarga", "七分盘力"], ["ojayugma", "奇偶力"], ["kendra", "角宫力"], ["drekkana", "三分力"]] as const; -const KALA_PARTS = [["nathonnata", "昼夜力"], ["paksha", "月相力"], ["tribhaga", "三分时力"], ["ayana", "至力"], ["hora", "时主力"]] as const; -const RELATION_ZH: Readonly> = { 2: "大友", 1: "友", 0: "中", [-1]: "敌", [-2]: "大敌" }; - -function PointTable({ view, board, group }: { view: ChartViewOk; board: ChartBoardState; group: PointGroup }) { - const rows = pointsOf(view, group); - if (!rows) return

这张表暂时没算出来。

; - const picked = board.selection?.kind === "point" ? board.selection.key : null; - const arudha = group === "arudha"; - return ( -
- - - - - - - {arudha ? : <>} - - - - - {rows.map((point) => { - const on = picked === point.key; - const pick = () => board.pickPoint(on ? null : point); - return ( - - - - - {arudha - ? - : <>} - - - ); - })} - -
名称{arudha ? "星座" : "黄道经度"}宫位来自月宿宿主足传统含义
- - {point.signLabel}{point.degreeInSign !== null ? ` ${formatDegreeMinutes(point.degreeInSign)}` : ""}{point.house}宫第 {point.sourceHouse ?? "—"} 宫 · 主 {point.sourceLord ?? "—"}{point.nakshatra ?? "—"}{point.nakshatraLord ?? "—"}{point.pada ?? "—"}{point.note}
-
- ); -} - -function StrengthTable({ view, board }: { view: ChartViewOk; board: ChartBoardState }) { - const [part, setPart] = useState<"total" | "sthana" | "kala" | "other">("total"); - const rows = view.shadbala ?? []; - if (rows.length === 0) return

这张盘没有返回六力。

; - const current = board.selection?.kind === "body" ? board.selection.key : null; - const head = part === "total" ? ["总分", "达标分", "达标鲁帕", "满足率", "差额"] - : part === "sthana" ? [...STHANA_PARTS.map(([, label]) => label), "小计"] - : part === "kala" ? [...KALA_PARTS.map(([, label]) => label), "小计"] - : ["方位力", "运动力", "自然力", "视相力"]; - return ( - <> -
- value && setPart(value as typeof part)}> - - 总表 - 位置力 - 时间力 - 其余四力 - - -
-
- - {head.map((label) => )} - - {rows.map((row) => { - const key = planetGlyphKey(row.name); - const on = key !== null && current === key; - const total = row.rupas * 60; - const required = row.required * 60; - let cells: ReactNode; - if (part === "total") { - cells = ( - <> - - - - - - - ); - } else if (part === "other") { - cells = row.parts - ? ["dig", "chesta", "naisargika", "drik"].map((id) => ) - : ; - } else { - const list = part === "sthana" ? STHANA_PARTS : KALA_PARTS; - const values = part === "sthana" ? row.sthanaParts : row.kalaParts; - cells = values - ? <> - {list.map(([id]) => )} - - - : ; - } - return ( - key && board.select(on ? null : { kind: "body", key })}> - - {cells} - - ); - })} - -
行星{label}
{total.toFixed(1)}{required.toFixed(0)}{row.required.toFixed(2)}{row.percent.toFixed(1)}%{STRENGTH_LEVEL_ZH[row.level]}= required ? "chart-graha-good" : "chart-graha-bad"}`}>{total >= required ? "+" : ""}{(total - required).toFixed(1)}{row.parts![id as "dig"].toFixed(1)}—{(values as Record)[id]!.toFixed(1)}{(part === "sthana" ? row.parts?.sthana : row.parts?.kala)?.toFixed(1) ?? "—"}—
{row.label}
-
-

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

- - ); -} - -function RelationTable({ view }: { view: ChartViewOk }) { - const [mode, setMode] = useState<"natural" | "temporary" | "compound">("compound"); - const friendship = view.points?.status === "ok" ? view.points.friendship : null; - if (!friendship) return

这张表暂时没算出来。

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

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

- - ); -} - -function BookTab({ view, board, onNeedLayer }: { - view: ChartViewOk; - board: ChartBoardState; - onNeedLayer?: (layer: ChartViewLayer) => void; -}) { - const natal = natalBoard(view); +function BookTab({ view }: { view: ChartViewOk }) { const calendar = view.birthCalendar; const moon = view.vedic.planets.find((row) => row.name === "Moon"); - const [table, setTable] = useState("planets"); - // The data tables read the points layer. - useEffect(() => { - onNeedLayer?.("points"); - }, [onNeedLayer]); - const pointsPending = view.points?.status !== "ok" && board.pointsPending; return (
@@ -770,83 +436,6 @@ function BookTab({ view, board, onNeedLayer }: {
) : null} -
- value && setTable(value as BookTable)}> - - {BOOK_TABLES.map(([id, label]) => {label})} - - - {table === "planets" ? ( -
-
-
- - - - - - - - - - - - {view.vedic.planets.map((row) => { - const key = planetGlyphKey(row.name); - const dignity = dignityStep(row.status); - return ( - key && board.select({ kind: "body", key })}> - - - - - - - ); - })} - -
星体位置月宿状态D9
- {row.label} - {row.signLabel} {formatDegreeMinutes(row.degreeInSign)} · {row.house}宫{row.nakshatra} - = 6 ? "chart-graha-good" : undefined}>{dignity?.label ?? "—"} - {row.retrograde && key !== "rahu" && key !== "ketu" ? " 逆" : ""} - {row.d9SignLabel ?? "—"}
-
-
-
-
- - - - - - - - - - {natal.houses.map((house) => { - const lord = houseLord(natal, house.house); - const at = lord ? bodyHouse(natal, lord) : null; - return ( - board.select({ kind: "house", house: house.house })}> - - - - - ); - })} - -
宫位宫主落宫
{house.house}宫 · {signLabelAt(house.sign)}{lord ? PLANET_NAME_ZH[lord] : "—"}{at ? `${at}宫 · ${signLabelAt(natal.houses[at - 1]!.sign)}` : "—"}
-
-
-
- ) : null} - {table === "upagraha" || table === "special" || table === "arudha" ? ( - pointsPending ?

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

: ) : null} -
); } @@ -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({ 深读 - 时运 · 能量 · 格局 · 底册 value && select(value as ChartDeepTab)}> @@ -900,7 +486,6 @@ export function ChartDeepReading({ {item.label} {item.id === "time" && now ? {PLANET_GLYPH_ZH[planetGlyphKey(now.maha.lord) ?? "sun"]}{now.antar ? ` · ${PLANET_GLYPH_ZH[planetGlyphKey(now.antar.lord) ?? "sun"]}` : ""} : null} - {item.id === "yoga" && formed > 0 ? {formed} 组 : null} {item.hintTab && journey?.tabHasDot(item.hintTab) ? : null} ))} @@ -923,8 +508,7 @@ export function ChartDeepReading({ {tab === "energy" ? ( ) : null} - {tab === "yoga" ? : null} - {tab === "book" ? : null} + {tab === "book" ? : null} ); diff --git a/frontend/src/components/chart-board/chart-inspector.tsx b/frontend/src/components/chart-board/chart-inspector.tsx index 22062ebe..de7ea294 100644 --- a/frontend/src/components/chart-board/chart-inspector.tsx +++ b/frontend/src/components/chart-board/chart-inspector.tsx @@ -180,7 +180,7 @@ function BodyDetail({ view, model, source, keyName, transitSky }: {

在{signLabelAt(transit.sign)} {formatDegreeMinutes(transit.degree)}{transit.retrograde ? "(逆行)" : ""} {natal ? `,本命第 ${natal.house} 宫` : ""} - {sav !== null && sav !== undefined ? `,该座八宫点 ${sav}` : ""} + {sav !== null && sav !== undefined ? `,该座八分点 ${sav}` : ""}

) : null} @@ -216,7 +216,7 @@ function HouseDetail({ view, model, source, house }: {
) : null} {sav !== null && sav !== undefined ? ( -
+
) : 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 ( + <> +

上升 {asc.signLabel} {formatDegreeMinutes(asc.degreeInSign)}

+

+ {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)}` : ""} +

+ + ); +} + export function ChartInspector({ view, model, @@ -252,12 +269,9 @@ export function ChartInspector({ 盘面检视 - 点星体或宫位 -

点盘上的星或宫看详情

-

点了之后,盘上会连出它管的宫和它看的宫,右边表格同一行也会亮。

-

方向键在九曜或十二宫间切换 · Esc 收起

+
); diff --git a/frontend/src/components/chart-board/chart-side-cards.tsx b/frontend/src/components/chart-board/chart-side-cards.tsx index b5288230..b4d87b8d 100644 --- a/frontend/src/components/chart-board/chart-side-cards.tsx +++ b/frontend/src/components/chart-board/chart-side-cards.tsx @@ -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 }: { 九曜 - 恒星黄道 · 本命
@@ -110,7 +108,6 @@ export function ChartHouseThemesCard({ model, selection, onSelect }: { 宫位主题 - 点一下,盘上对应的宫亮起 {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(null); - const periods = view.dasha.vimshottari.periods; - const shown = periods.find((period) => period.start === open) ?? now?.maha ?? periods[0]; - return ( - - - Vimshottari 大运 - 点一段,下面看分运 - - -
- {periods.map((period) => { - const isNow = now?.maha.start === period.start; - const isOpen = shown?.start === period.start; - return ( - - ); - })} -
- {shown ? ( -
- {shown.antardashas.map((antar) => ( - - {(() => { const key = planetGlyphKey(antar.lord); return key ? PLANET_GLYPH_ZH[key] : antar.label; })()} {antar.start.slice(0, 7).replace("-", ".")} - - ))} -
- ) : null} -
-
- ); -} - export function ChartGlanceCard({ view, model, onSelect, onOpenTime }: { view: ChartViewOk; model: BoardModel; @@ -209,7 +159,6 @@ export function ChartGlanceCard({ view, model, onSelect, onOpenTime }: { 命盘速览 - 点一项,在盘上定位 {glance.ascendant ? item("asc", "上升", glance.ascendant.sign, formatDegreeMinutes(glance.ascendant.degree), () => onSelect({ kind: "house", house: model.ascendantHouse })) : null} diff --git a/frontend/src/components/chart-board/chart-type-table.tsx b/frontend/src/components/chart-board/chart-type-table.tsx index 93fb3377..9b8104dc 100644 --- a/frontend/src/components/chart-board/chart-type-table.tsx +++ b/frontend/src/components/chart-board/chart-type-table.tsx @@ -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 = ( - <> -

{CHART_VIEW_COPY.bhavaNote}

- - - ); + body = ; } else if (type === "TRANSIT" && view.transit.status === "ok") { title = "行运"; - body = ( - <> -

{CHART_VIEW_COPY.transitNote}

- - - ); + body = ; } 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 ( - - - {title} - - {body} - +
+

{title}

+ {body} +
); } diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index 55877359..f32804c9 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -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 = new Set(["upagraha", "special", "arudha"]); +/** Data tabs that own a board layer; leaving one puts the board back on 基础星盘. */ +const LAYER_TABS: ReadonlySet = 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("planets"); + const [picks, setPicks] = useState(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 ) : null}
-
+
- board.setType("D9")} - /> -
-
- +
+ setPicks((current) => ({ ...current, ...next }))} + pendingLayers={pendingLayers} + layerFailures={layerFailures} + onNeedLayer={onNeedLayer} + /> +
+ board.setType("D9")} + /> +
{board.model ? : null} - {board.model ? : null}
diff --git a/frontend/src/components/chat-natal-chart.tsx b/frontend/src/components/chat-natal-chart.tsx index 65966deb..a357892d 100644 --- a/frontend/src/components/chat-natal-chart.tsx +++ b/frontend/src/components/chat-natal-chart.tsx @@ -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} diff --git a/frontend/src/hooks/use-chart-board.ts b/frontend/src/hooks/use-chart-board.ts index 10a5e756..ed010a0e 100644 --- a/frontend/src/hooks/use-chart-board.ts +++ b/frontend/src/hooks/use-chart-board.ts @@ -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("D1"); const [selection, setSelection] = useState(null); const [layer, setLayerState] = useState("none"); - const [yogaBodies, setYogaBodies] = useState(null); /** 标点 layer: every point of one group drawn on the board at once (issue #114). */ const [pointLayer, setPointLayerState] = useState("none"); @@ -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) => { 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(); - 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, diff --git a/frontend/src/lib/chart-board-focus.ts b/frontend/src/lib/chart-board-focus.ts new file mode 100644 index 00000000..210325ec --- /dev/null +++ b/frontend/src/lib/chart-board-focus.ts @@ -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; + /** Drawn faint. */ + dimmed: ReadonlySet; + marks: ReadonlyMap; + houses: ReadonlySet; + links: readonly FocusLink[]; +}; + +export const RELATION_ZH: Readonly> = { 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; + 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(); + 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(); + 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(); + 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; +} diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 910affd0..ba53634c 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -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; export type ChartViewPointsOk = z.infer; export type ChartViewPoint = z.infer; +export type ChartViewKaraka = z.infer; export const CHART_VIEW_TABS = [ { id: "vedic", label: "星盘" }, diff --git a/frontend/src/lib/chart-view-insight.ts b/frontend/src/lib/chart-view-insight.ts index fc2e6fdb..0a63a948 100644 --- a/frontend/src/lib/chart-view-insight.ts +++ b/frontend/src/lib/chart-view-insight.ts @@ -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 | 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; diff --git a/frontend/src/lib/drag-scroll.ts b/frontend/src/lib/drag-scroll.ts new file mode 100644 index 00000000..4b319dd3 --- /dev/null +++ b/frontend/src/lib/drag-scroll.ts @@ -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) { + 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); +} diff --git a/frontend/tests/chart-board-20261027.test.tsx b/frontend/tests/chart-board-20261027.test.tsx index 36567acd..2b3caa1b 100644 --- a/frontend/tests/chart-board-20261027.test.tsx +++ b/frontend/tests/chart-board-20261027.test.tsx @@ -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); }); diff --git a/frontend/tests/chart-data-card-20261030.test.tsx b/frontend/tests/chart-data-card-20261030.test.tsx new file mode 100644 index 00000000..1c8dfa1a --- /dev/null +++ b/frontend/tests/chart-data-card-20261030.test.tsx @@ -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; + varga_full: Record; +}; +const insight = JSON.parse(readFileSync(new URL("./fixtures/chart-insight-golden.json", import.meta.url), "utf8")) as { + chart: { planets: Record } & Record; + chart_modules_shadbala: Record; + ashtakavarga: Record; + bhava_bala: Record; + chart_points: Record; +}; +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 { + 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(["sun"]), + dimmed: new Set(["ketu"]), + marks: new Map([["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"[^>]*>大运 { + 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(/]*>([^<]+) 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}.*?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(); + } +}); diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index 6e0f38ff..7e866616 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -227,8 +227,14 @@ test("the four tabs render and can each be selected", async () => { // 旧 initialTab 仍可用:basics → 排盘底册,dasha → 时运流年,western → 西洋盘。 assert.match(markup, />印度盘西洋盘 { 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(); diff --git a/scripts/chart_view_points/api.py b/scripts/chart_view_points/api.py index f24db35a..ad08de68 100644 --- a/scripts/chart_view_points/api.py +++ b/scripts/chart_view_points/api.py @@ -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), } diff --git a/tests/test_chart_view_points_api.py b/tests/test_chart_view_points_api.py index a0fc9a7f..ddab7186 100644 --- a/tests/test_chart_view_points_api.py +++ b/tests/test_chart_view_points_api.py @@ -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