From 5a2d9da98533e435b1f7f1a00bdb6c7c7b3a849a Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 25 Sep 2026 02:59:18 +0800 Subject: [PATCH] feat: fold dasha periods and redraw the western wheel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dasha tab leads with a now card and a duration bar, and keeps sub-periods inside the current details element. A zero-year Chara row stays grey, shows 0 年, and cannot expand. The tropical wheel uses a 520 view box, element bands, separated planet labels, and aspect lines that stay visible while a selected body is highlighted. --- CHANGELOG.md | 7 + ...S-chart-dasha-western-redesign-20260924.md | 49 ++++ docs/tasks/README.md | 2 +- docs/testing/chart-dasha-western-20260924.md | 14 + frontend/DESIGN.md | 29 ++ frontend/docs/VOICE.md | 4 +- frontend/src/app/globals.css | 203 ++++++++++++- .../components/chart-page/chart-dasha-tab.tsx | 148 ++++++++-- .../chart-page/chart-western-tab.tsx | 149 ++++++++-- .../chart-page/western-wheel-layout.ts | 260 +++++++++++++++++ .../chart-page/western-wheel-svg.tsx | 275 +++++++++++++++--- frontend/src/lib/chart-view-contract.ts | 3 + frontend/src/lib/chart-view-mapper.ts | 1 + frontend/tests/chart-page-view.test.tsx | 61 ++++ frontend/tests/chart-view-zero-year.test.ts | 16 + .../fixtures/western-1990-01-01-beijing.json | 33 +++ frontend/tests/western-wheel-layout.test.ts | 155 ++++++++++ 17 files changed, 1305 insertions(+), 104 deletions(-) create mode 100644 docs/tasks/PROGRESS-chart-dasha-western-redesign-20260924.md create mode 100644 docs/testing/chart-dasha-western-20260924.md create mode 100644 frontend/src/components/chart-page/western-wheel-layout.ts create mode 100644 frontend/tests/fixtures/western-1990-01-01-beijing.json create mode 100644 frontend/tests/western-wheel-layout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 912190f8..261f8ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-25 — 星盘大运改为折叠,西洋盘重画 + +- 大运每条轨道先给「现在」卡和时间条,子运收在当前段里。0 年段仍是灰色「0 年」,不占时间轴,不能展开。 +- 西洋盘改成 520 见方的三圈盘:星座按元素上色,行星避开重叠,宫位号放在宫中,相位线一直显示。点一颗星只突出它和它的相位。 +- 右侧新增行星位置表。表格不再沿用印度盘那张 34rem 宽表。 +- Skill 版本不 bump。浏览器和部署未验收。 + ## 2026-09-25 — 首页内部重构,无用户可见变化 - 首页启动流程、咨询恢复轮询、错误屏和引导屏从首页组件拆到独立模块。落点、20 秒错误屏、局部重试、pending 优先级、恢复轮询和文案都不变。 diff --git a/docs/tasks/PROGRESS-chart-dasha-western-redesign-20260924.md b/docs/tasks/PROGRESS-chart-dasha-western-redesign-20260924.md new file mode 100644 index 00000000..5a300ada --- /dev/null +++ b/docs/tasks/PROGRESS-chart-dasha-western-redesign-20260924.md @@ -0,0 +1,49 @@ +# PROGRESS · 星盘大运 Tab 与西洋盘 Tab 改版(2026-09-25) + +## 当前结论 + +T1、T2、T3 都已完成。让步 1 用了第二层半径,但触发条件比任务书写的「≥6 颗落在 30° 内」更宽:虚构 1990-01-01 的摩羯是 5 颗,推到 11° 之后 17 号字仍然叠,所以改用 188 / 136 两层半径,没有把字号降到 12 以下。让步 2 用了:`view.western` 没有 MC 字段,盘内 MC 取第 10 宫头。在这份 Placidus 引擎输出里,第 10 宫头就是 MC。没有改接口、引擎、`scripts/jaimini.py`、`HEAVY_COMPUTE_PATHS`、数据库或 workflow。没有开 BUG 号。BUG-1029 开工时仍空着,本单不用。 + +浏览器登录验收没做。没有推 `staging`,也没有推 `main`。功能分支可以备份到 origin。 + +- 工作树:`G:\Ferti\Jyotisha\.worktrees\chart-dasha-western-redesign-20260924` +- 分支:`codex/chart-dasha-western-redesign-20260924` +- 基线:`origin/staging` = `9167d75e`(0 年大运实现 `19ea0f06` 已在其中) + +## 逐项 + +| 项 | 结果 | 说明 | +| --- | --- | --- | +| T1 | 完成 | 每轨一张「现在」卡、一条时间条。golden 上时间条段数等于大运数。只有当前段 `
`。0 年段灰色、写「0 年」、不是 `
`,时间条上没有它。 | +| T2 | 完成 | `layoutWesternLabels` 在 1990-01-01 引擎黄经上把任意两颗显示角拉开到 ≥ 11°,标签框在 520 viewBox 内且互不重叠。宫位号在宫中。点选过滤相位表。骨架只有三圈和 12 条分割线。 | +| T3 | 完成 | DESIGN §15、VOICE、CHANGELOG、本记录、真人清单、状态板。 | + +## 缺的 western 字段 + +没有向 `/api/chart-view` 或 `/api/western` 要新字段。 + +- 没有 `mc`。原始包有 `natal.mc`,mapper 没往 `view.western` 放。盘内 MC 用第 10 宫 `longitude`。 +- 没有行星符号字段。符号按现有 `id` 映射,天王、海王、冥王不在那套 9 字形字体里,落到系统符号字体。 +- 没有星群字段。同一 `signLabel` 至少 3 颗才写「星群」。 +- `modes` 仍在 `view.western` 上,D11 的 chips 没有模式,页面不再画「元素 / 模式」那一段计数。 +- 相位的 `left` / `right` 已是中文名,点选按中文名对,不是行星 id。 +- 大运「现在」卡的月份用 mapper 已经在用的 `asOf`,加在 `view.dasha.asOf` 上,不是新的引擎请求。 + +## 测试 + +改代码前,在 `frontend`: + +- `npx tsx --test tests/chart-page-view.test.tsx`:19 通过,0 失败 +- `npx tsx --test tests/chart-view-zero-year.test.ts`:3 通过,0 失败 + +改完后同一组加上 `tests/western-wheel-layout.test.ts`、`tests/dark-theme-contract.test.ts`、`tests/class-name-definition-contract.test.ts`、`tests/chart-view-route.test.ts`:失败 0。改前那 22 条里没有新增失败。 + +- `.\node_modules\.bin\tsc --noEmit`:通过。 +- `npm run lint`:0 error,121 条既有 warning。本次没有新增 warning,也没有为了清 warning 改业务代码。 +- 没有改既有断言的期望值,所以没有「原值 / 新值 / 原因」三栏。新增的是大运折叠、西洋过滤和标签几何。 +- 没有跑 `npm test`,没有跑 `next build`。源码没有把 `/chart` 改成动态路由,但不能声称本机构建复核过 Static。 +- 没有登录浏览器,375 / 1280、摩羯星群、点行星、暗色都写在 `docs/testing/chart-dasha-western-20260924.md`,结果是待验。 + +## 0 年段 + +仍不可展开。显示「0 年」和「本段时长为 0,不展开」,不进时间条,子运列表不渲染。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 206d8ee5..dfb9d86e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -202,7 +202,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-chart-dasha-western-redesign-20260924.md` | — | **大运 / 西洋盘改版**(原型 https://claude.ai/artifact/DYovEPMqD1xwNgna5xVTre ,产品 09-24 全部同意,相位线常显):大运「现在」卡 + 时间条 + 默认折叠(原 81/144 行平铺);西洋盘 520 三圈、行星避让 + 引线、宫号居宫中、ASC/MC 盘内、行星位置表、点选过滤相位。排在 chart-ephemeris-fixes T1 之后 | 待领取 | — | +| `TASK-chart-dasha-western-redesign-20260924.md` | `PROGRESS-chart-dasha-western-redesign-20260924.md` | **大运 / 西洋盘改版**(原型 https://claude.ai/artifact/DYovEPMqD1xwNgna5xVTre ,产品 09-24 全部同意,相位线常显):大运「现在」卡 + 时间条 + 默认折叠(原 81/144 行平铺);西洋盘 520 三圈、行星避让 + 引线、宫号居宫中、ASC/MC 盘内、行星位置表、点选过滤相位。排在 chart-ephemeris-fixes T1 之后 | 待验收 | — | | `TASK-chart-ephemeris-fixes-20260924.md` | `PROGRESS-chart-ephemeris-fixes-20260924.md` | **Chara 0 年大运崩溃 + 星历切日期旧数据 + 星盘无资料空状态(BUG-1023/1024/1025)**:mapper 0/0 → `RangeError`(约 7.8% 星盘,产品定照实显示 0 年、不改引擎);BUG-966 删掉重置致日期与数据分叉(回归);无资料只有一句话,加「去填写出生资料」+ `?settings=chart` 深链(T3 排在 home-slow-network 之后)。大运 / 西洋版式重做不在本单 | 待验收 | `19ea0f06` | | `TASK-chart-page-skeleton-wait-20260924.md` | `PROGRESS-chart-page-skeleton-wait-20260924.md` | **星盘页骨架盘等待态 + 三个「永远还没拿到」死角**:「这一张盘还没拿到。」只代表 `view === null`,是唯一等待态,失败各有句子;但客户端无超时(A)、401 跳转前(B)、分盘层失败 / 429 后 `ChartVedicTab` 只看 `!selected` 不看 pending(C)三条路都让这句永远挂着。**产品 09-24 拍板推翻 09-18 方案一(BUG-966)**:星盘页盘位用骨架盘 + 呼吸动效(复用 `VedicChartSvg` 空模型,不复制几何;`prefers-reduced-motion` 静止),范围只限星盘页会出盘轮的位置,首页 / 侧栏 / 星历 / 报告的「不放骨架」合同不动;`AGENTS.md` §6 与 `DESIGN.md` §9 / §15 追加例外。三死角出口:客户端 25s 超时 → `timedOut` 句、401 先写失败体、按层失败态 `layerFailures`(429 独立档,BUG-715/723)。BUG-1016 / 1017 | 已验收(Claude 09-24:红线全绿、+46 测试、失败名单同基线、四路由标记与 gzip 同基线;BUG-1016/1017 resolved) | `1420471a`(staging 已部署);真机清单欠 | | `TASK-smalltalk-test-stdout-mock-20260921.md` | `PROGRESS-smalltalk-test-stdout-mock-20260921.md` | **测试基础设施(BUG-995)**:`consultation-smalltalk.test.ts` 为验证「SDK 不得把私密文本写进日志」,用 `t.mock.method(process.stdout/stderr, "write", …)` 全局接管标准输出——而 `node:test` 的 TAP 报告也走 `process.stdout`,于是运行器自己的 `# Subtest:` / `ok` 行被吞进测试的 `logs` 数组。实测四次全量:`default Mastra adapter` 四条用例只上报 4 / 2 / 1 / 1 条(`Subtest` 声明本身就少,不是 grep 锚点问题),`# tests` 因此 ±3 抖;单跑该文件 33 条静态展开只报 30 条,仅循环最后一个 `provider_error` 稳定可见。**更要紧**:注入必失败探针后,`invalid_schema` / `bad_json` 只剩一行 `not ok 1 - <文件绝对路径>`,**没有用例名、没有断言消息**(退出码仍是 1,门禁不会漏掉失败,不夸大)。定位 BUG-987 时靠的就是从门禁日志 grep `not ok` 抓用例名,红在这三条上那条路会断。**决策**:mock 改为记录后**透传给原始 `write`**(保留隐私断言,不换报告通道,不改 `npm test` 脚本);F3 把「比用例名列表 diff、不比 `# tests` 总数」写进 `frontend/AGENTS.md`。全仓只此一个文件用了这个手法(已 grep 确认)| 待验收 | `2503c019` | diff --git a/docs/testing/chart-dasha-western-20260924.md b/docs/testing/chart-dasha-western-20260924.md new file mode 100644 index 00000000..8192dd26 --- /dev/null +++ b/docs/testing/chart-dasha-western-20260924.md @@ -0,0 +1,14 @@ +# 星盘大运与西洋盘改版(2026-09-25) + +状态:本环境没有登录浏览器,下面每条都还没在真人浏览器里走过。自动化锁了渲染、折叠、0 年段和标签几何,不能代替这张表。只用虚构盘:1990-01-01 12:00 北京,以及已入库的 1985-08-03 03:59 北京。不要写入真实出生资料。 + +| 项目 | 操作 | 通过标准 | 结果 | +| --- | --- | --- | --- | +| 窄屏 375 | 打开星盘,点「大运」,再点「西洋盘」 | 大运上下排,不出现整页横滚。西洋盘不超过屏宽,相位表和行星表在自己的盒子里横滚。可点的大运行和表行至少 44px。没有转圈或「正在加载」 | 待验 | +| 宽屏 1280 | 同一张盘,窗口放到 1280 | 大运左 Vimshottari、右 Chara。西洋盘居中且不超过 520px,右栏依次是要点、行星位置、主要相位、口径说明 | 待验 | +| 摩羯星群 | 用虚构 1990-01-01 12:00 北京打开西洋盘 | 摩羯里的行星名和度数不互相压住,也不压星座名、宫位号。盘外没有单独一个「升」字。ASC、MC 在盘内 | 待验 | +| 点行星 | 点盘上的太阳,再点一次;也点行星表里的同一行 | 相位线一直都在。点中后只突出太阳和它的相位,相位表只剩太阳。再点恢复全部 | 待验 | +| 0 年大运 | 用虚构 1985-08-03 03:59 北京打开大运 | 射手那段是灰色「0 年」,不能展开,没有子运。时间条上没有这段。别的当前段默认展开 | 待验 | +| 暗色 | 系统或页面切到暗色,看西洋盘星座环 | 火土风水四色还能分清,星座名在色块上读得清。相位的绿和红不是亮色模式下的那两个深色 | 待验 | + +部署后还要核对 staging `/api/health` 的 `deployment.gitCommit`。本清单未走完之前,不能写成浏览器已验收。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index aa428cf7..a4b0af2a 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -920,6 +920,35 @@ Three product rules for assistant markdown in `.message-markdown` (BUG-962 / 963 0 年大运照实显示,不改引擎。某段 Chara 的 `duration_years` 为 0 时,这一段标注「0 年」,起止落在同一天,不占时间轴,也不展开子运;子运区写「本段时长为 0,不展开」。前一段的结束日就是下一段的开始日。 +### 大运 Tab(2026-09-25) + +每条轨道自上而下是「现在」卡、时间条、可折叠大运列表。≥1024 时 Vimshottari 在左、Chara 在右;更窄则上下排。底部仍是那句双轨说明。 + +「现在」卡三行:大运、小运、小运剩余 N 个月。月份按排盘所用的 `asOf` 到当前小运结束日的整月数,结束日当天不往下减。没有落入的时段写「未落入已返回的时段」,没有小运写「—」。 + +时间条只画时长大于 0 的段,宽度按时长。当前段用主文字色内描边,一条竖线标 `asOf`。两端是 `YYYY.MM`。0 年段不进这条轴。段色轮换现有的浅底 token(`--color-action-soft`、`--color-success-muted`、`--color-canvas-strong`、`--color-danger-muted`、`--color-selected`、`--color-canvas-muted`),不新造一套色。 + +列表用原生 `
` / ``。只有当前段带 `open`。子运一行是「名称 · YYYY.MM – YYYY.MM」,日期 `tabular-nums` 靠右,当前子运用主文字色。`summary` 与子运行高 44px。未展开的子运列表用 `details:not([open]) > ul { display: none }` 收起。0 年段不是 `
`,文字用 `--color-ink-tertiary`,只写「0 年」和「本段时长为 0,不展开」。 + +### 西洋盘 Tab(2026-09-25) + +盘 `viewBox` 是 `0 0 520 520`,容器 `width: 100%`、`max-width: 520px`、水平居中。三圈:外圈星座、中圈行星、内圈宫位号。骨架模式只画这三圈和 12 条星座分割线,不画星座名、行星、宫位号、相位。 + +星座环按元素填色,token 在 `:root` 与两套暗色里各一份: + +| token | 亮色 | 暗色 | 星座 | +| --- | --- | --- | --- | +| `--color-element-fire` | `#f4d3c3` | `#5a3a32` | 白羊、狮子、射手 | +| `--color-element-earth` | `#e7dfc6` | `#4a4636` | 金牛、处女、摩羯 | +| `--color-element-air` | `#d5e2f2` | `#2e3c4e` | 双子、天秤、水瓶 | +| `--color-element-water` | `#d3e6e8` | `#2a4448` | 巨蟹、天蝎、双鱼 | + +星座名用 `--color-ink`。这四个填色不是通用文字底,三级字和强调色不铺在上面。相位:合 / 六分 / 三分用 `--color-success`,刑 / 冲用 `--color-danger`。组件里不写死色值。 + +行星标的是符号加星座内整度,逆行再加 ℞。字号 17(viewBox 单位),在 375px 宽的盘上仍不低于 12px。相邻显示角小于 11° 时沿黄经推开。推开后同一半径的字框仍会叠,就改用第二层半径(188 与 136),不再把字号降到 12 以下。真实位置在星座环内缘留刻度,并用细线连到显示位置。宫位号放在宫头到下一宫头的中点,不压在宫头线上。1 / 4 / 7 / 10 宫头加粗。ASC、MC 写在盘内;不再画盘外的「升」。`view.western` 没有 MC 字段,MC 用第 10 宫头。 + +相位线一直画着。点行星或行星位置表的一行,只留下该星和它的相位,其余变淡;再点取消。相位表同步只列该星。右栏顺序:要点 chips(上升、太阳、月亮、元素最多、星群)→ 行星位置表 → 主要相位表 → 口径说明。星群是同一星座里至少 3 颗行星,由现有 `planets` 数出来,不是新字段。表用 `.chart-page-western-table`,不继承 `min-width: 34rem`,外包 `.chart-page-western-table-wrap`(`min-width: 0`、`overflow-x: auto`)。断点仍是 1024。 + ### 等待态 2026-09-24 产品决策有限推翻 09-18 方案一(BUG-716 / 966):`SecondaryPageShell` 标题、note 槽与撑满内容区不变,主盘未到时正常渲染 body 和禁用的 Tab 行;仅 D1、非 D1 分盘与西洋盘位置显示既有 SVG 空模型的骨架与原等待句。骨架无星体、度数、星座标签,几何不得复制;容器 `aria-busy=true`,装饰盘对读屏隐藏。中性 token、opacity 0.35 ↔ 0.6 / 2.4s ease-in-out,减弱动态效果时静止 0.5;不旋转、不扫光。大运、七政表格仍为静态句,首页、侧栏、星历、报告不适用此例外。首次请求(含预取)和按需层均有 25 秒客户端期限;失败移除骨架,再点该 Tab/chip 清失败并重试。401 先显式呈现登录句再跳转。同一会话再次进入直接用模块缓存,不重放等待句。出生资料保存成功后,缓存按账户和资料指纹换键,旧盘不得留在页上冒充新的日期或时间。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index bad1a58c..7431aa4e 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -24,7 +24,9 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 - 星历事件还在取:「换座和停滞还在取。」 - 星历这一天没取到:「这一天的星历没取到,点日期再试一次。」 -- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。 +- 0 年大运不展开:「本段时长为 0,不展开。」时长本身写「0 年」。这一行是灰色,不能点开。 +- 大运「现在」卡只报三件事:大运名称、小运名称、`小运剩余 N 个月`。没有落入已返回的时段时,大运写「未落入已返回的时段」,小运和小运剩余写「—」。不解释吉凶。 +- 西洋盘要点只报位置,例如:`上升 处女 21°40′`、`太阳 双子 23°48′`、`月亮 双鱼 10°55′`、`元素最多 土`、`星群 摩羯`。同一星座少于 3 颗就不写星群。点中的行星没有主要相位时,相位表写「没有主要相位」。 没有出生资料时,标题是「还没有星盘资料」,按钮是「去填写出生资料」。资料读不出来、已采用时间缺时区、时区没有对上,正文仍用各自的原句,只共用这颗按钮。 diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3d1713e4..81346d58 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -141,6 +141,12 @@ --color-planet-saturn: #5b6bb0; --color-planet-node: #6b7280; --color-planet-ascendant: #b0413e; + /* Zodiac-band fills. Sign names sit on them in --color-ink, not as general + text surfaces. Dark values are in both dark blocks. */ + --color-element-fire: #f4d3c3; + --color-element-earth: #e7dfc6; + --color-element-air: #d5e2f2; + --color-element-water: #d3e6e8; --shadow-soft: 0 1px 2px rgba(29, 29, 31, .06), 0 8px 20px -18px rgba(29, 29, 31, .2); --shadow-elevated: 0 1px 2px rgba(29, 29, 31, .07), 0 12px 28px -16px rgba(29, 29, 31, .18); /* Display and body share one sans stack. The old value led with two Anthropic @@ -295,6 +301,10 @@ --color-planet-saturn: #8d9be0; --color-planet-node: #a1a7b3; --color-planet-ascendant: #e07b78; + --color-element-fire: #5a3a32; + --color-element-earth: #4a4636; + --color-element-air: #2e3c4e; + --color-element-water: #2a4448; } } @@ -379,6 +389,10 @@ --color-planet-saturn: #8d9be0; --color-planet-node: #a1a7b3; --color-planet-ascendant: #e07b78; + --color-element-fire: #5a3a32; + --color-element-earth: #4a4636; + --color-element-air: #2e3c4e; + --color-element-water: #2a4448; } @@ -5062,9 +5076,75 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class } .chart-page-planet-card p { margin: 0 0 var(--space-2); font-size: var(--type-body-sm); } .chart-page-symbol { color: var(--color-ink-tertiary); } +.chart-page-dasha-now { + display: grid; + gap: var(--space-2); + margin: var(--space-3) 0; + padding: var(--space-3); + border-radius: var(--radius-sm); + background: var(--color-canvas-muted); +} +.chart-page-dasha-now h4 { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-caption); + font-weight: 500; +} +.chart-page-dasha-now dl { display: grid; gap: var(--space-1); margin: 0; } +.chart-page-dasha-now div { + display: flex; + justify-content: space-between; + gap: var(--space-3); + align-items: baseline; +} +.chart-page-dasha-now dt { + color: var(--color-ink-tertiary); + font-size: var(--type-caption); +} +.chart-page-dasha-now dd { + margin: 0; + color: var(--color-ink); + font-size: var(--type-body-sm); + font-variant-numeric: tabular-nums; + text-align: right; +} +.chart-page-dasha-axis { margin: 0 0 var(--space-4); } +.chart-page-dasha-bar-wrap { position: relative; } +.chart-page-dasha-bar { + display: flex; + height: 12px; + overflow: hidden; + border-radius: 999px; + background: var(--color-canvas); + gap: 1px; +} +.chart-page-dasha-segment { min-width: 0; height: 100%; } +.chart-page-dasha-segment:nth-child(6n + 1) { background: var(--color-action-soft); } +.chart-page-dasha-segment:nth-child(6n + 2) { background: var(--color-success-muted); } +.chart-page-dasha-segment:nth-child(6n + 3) { background: var(--color-canvas-strong); } +.chart-page-dasha-segment:nth-child(6n + 4) { background: var(--color-danger-muted); } +.chart-page-dasha-segment:nth-child(6n + 5) { background: var(--color-selected); } +.chart-page-dasha-segment:nth-child(6n + 6) { background: var(--color-canvas-muted); } +.chart-page-dasha-segment.is-current { box-shadow: inset 0 0 0 2px var(--color-ink); } +.chart-page-dasha-today { + position: absolute; + top: -2px; + width: 2px; + height: 16px; + background: var(--color-ink); + transform: translateX(-1px); +} +.chart-page-dasha-scale { + display: flex; + justify-content: space-between; + margin-top: var(--space-1); + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + font-variant-numeric: tabular-nums; +} .chart-page-dasha-list { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--space-2); } .chart-page-dasha-period { - padding: var(--space-3); + padding: var(--space-2) var(--space-3); border: 1px solid transparent; border-radius: var(--radius-sm); } @@ -5073,6 +5153,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class border-color: var(--color-border-strong); box-shadow: inset 2px 0 0 var(--color-focus); } +.chart-page-dasha-period.is-zero { color: var(--color-ink-tertiary); } +.chart-page-dasha-period.is-zero span { margin-left: auto; } .chart-page-dasha-period p { display: flex; flex-wrap: wrap; @@ -5080,14 +5162,53 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class margin: 0; font-size: var(--type-body-sm); } +.chart-page-dasha-period summary { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 44px; + cursor: pointer; + list-style: none; + font-size: var(--type-body-sm); +} +.chart-page-dasha-period summary::-webkit-details-marker { display: none; } +.chart-page-dasha-period summary::before { + content: ""; + width: 0.45em; + height: 0.45em; + flex: 0 0 auto; + border-right: 1.5px solid var(--color-ink-tertiary); + border-bottom: 1.5px solid var(--color-ink-tertiary); + transform: rotate(-45deg); +} +.chart-page-dasha-period details[open] > summary::before { transform: rotate(45deg); } +.chart-page-dasha-period details:not([open]) > ul { display: none; } +.chart-page-dasha-summary-label { min-width: 0; } +.chart-page-dasha-dates { + margin-left: auto; + color: var(--color-ink-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} .chart-page-dasha-period ul { - margin: var(--space-2) 0 0; + margin: 0; padding: 0; list-style: none; color: var(--color-ink-secondary); font-size: var(--type-caption); } -.chart-page-dasha-period ul li.is-current { color: var(--color-ink); font-weight: 500; } +.chart-page-dasha-antar { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 44px; +} +.chart-page-dasha-antar.is-current { color: var(--color-ink); font-weight: 500; } +.chart-page-western-wheel { + width: 100%; + max-width: 520px; + margin-inline: auto; +} .chart-page-western-svg { display: block; width: 100%; height: auto; color: var(--color-ink); } .chart-page-western-ring, .chart-page-western-spoke, @@ -5096,10 +5217,78 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class stroke: var(--color-border-strong); stroke-width: 1.25; } -.chart-page-western-core { fill: var(--color-canvas-muted); stroke: var(--color-border-strong); } -.chart-page-western-svg text { fill: currentColor; font-size: 16px; } -.chart-page-western-house-label, -.chart-page-western-asc { fill: var(--color-ink-tertiary); } +.chart-page-western-house.is-axis { stroke: var(--color-ink-secondary); stroke-width: 2.4; } +.chart-page-western-sign { stroke: none; } +.chart-page-western-sign[data-element="fire"] { fill: var(--color-element-fire); } +.chart-page-western-sign[data-element="earth"] { fill: var(--color-element-earth); } +.chart-page-western-sign[data-element="air"] { fill: var(--color-element-air); } +.chart-page-western-sign[data-element="water"] { fill: var(--color-element-water); } +.chart-page-western-svg text { fill: currentColor; font-size: 17px; } +.chart-page-western-sign-label { fill: var(--color-ink); } +.chart-page-western-house-label { fill: var(--color-ink-secondary); } +.chart-page-western-angle { fill: var(--color-ink); font-weight: 600; } +.chart-page-western-planet { cursor: pointer; } +.chart-page-western-planet text { + font-family: var(--font-planet-glyphs), "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", sans-serif; +} +.chart-page-western-hit { fill: var(--color-canvas); opacity: 0; } +.chart-page-western-leader, +.chart-page-western-tick { + fill: none; + stroke: var(--color-ink-tertiary); + stroke-width: 0.8; +} +.chart-page-western-aspect { fill: none; stroke-width: 1.15; } +.chart-page-western-aspect[data-tone="soft"] { stroke: var(--color-success); } +.chart-page-western-aspect[data-tone="hard"] { stroke: var(--color-danger); } +.chart-page-western-aspect[data-tone="other"] { stroke: var(--color-border-strong); } +.chart-page-western-svg[data-filtering="true"] .chart-page-western-planet:not(.is-selected), +.chart-page-western-svg[data-filtering="true"] .chart-page-western-leader:not([data-active="true"]), +.chart-page-western-svg[data-filtering="true"] .chart-page-western-tick { opacity: 0.28; } +.chart-page-western-svg[data-filtering="true"] .chart-page-western-aspect:not([data-active="true"]) { opacity: 0.12; } +.chart-page-western-chips { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: 0 0 var(--space-4); + padding: 0; + list-style: none; +} +.chart-page-western-chips li { + padding: var(--space-2) var(--space-3); + border-radius: 999px; + background: var(--color-canvas-muted); + color: var(--color-ink); + font-size: var(--type-body-sm); +} +.chart-page-western-table-wrap { + min-width: 0; + overflow-x: auto; + overscroll-behavior-x: contain; +} +.chart-page-western-table { + width: 100%; + border-collapse: collapse; + background: var(--color-canvas); + font-size: var(--type-body-sm); +} +.chart-page-western-table th, +.chart-page-western-table td { + height: 44px; + padding: 0 var(--space-3); + border-bottom: 1px solid var(--color-border); + text-align: left; +} +.chart-page-western-table thead th { + background: var(--color-canvas-muted); + color: var(--color-ink-secondary); + font-size: var(--type-caption); + font-weight: 500; +} +.chart-page-western-num { font-variant-numeric: tabular-nums; } +.chart-page-western-table tbody tr[tabindex] { cursor: pointer; } +.chart-page-western-table tbody tr[tabindex]:hover { background: var(--color-canvas-muted); } +.chart-page-western-table tr.is-selected { background: var(--color-selected); } .chart-page-qizheng-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); diff --git a/frontend/src/components/chart-page/chart-dasha-tab.tsx b/frontend/src/components/chart-page/chart-dasha-tab.tsx index 2e7e3758..82ee5570 100644 --- a/frontend/src/components/chart-page/chart-dasha-tab.tsx +++ b/frontend/src/components/chart-page/chart-dasha-tab.tsx @@ -2,46 +2,135 @@ import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import type { ChartViewFailureStatus, ChartViewOk } from "@/lib/chart-view-contract"; import { CHARA_ZERO_YEAR_LABEL, CHARA_ZERO_YEAR_NOTE, CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +type DashaTrack = ChartViewOk["dasha"]["vimshottari"]; +type DashaPeriod = DashaTrack["periods"][number]; + +function formatYearMonth(iso: string): string { + return `${iso.slice(0, 4)}.${iso.slice(5, 7)}`; +} + +function dayMs(iso: string): number { + return Date.parse(`${iso.slice(0, 10)}T00:00:00Z`); +} + +/** Whole months from `asOf` until `endIso`. The end day is exclusive in the mapper. */ +export function dashaRemainingMonths(endIso: string, asOf: string): number { + const [year, month, day] = asOf.slice(0, 10).split("-").map(Number); + const [endYear, endMonth, endDay] = endIso.slice(0, 10).split("-").map(Number); + if (!year || !month || !day || !endYear || !endMonth || !endDay) return 0; + let months = (endYear - year) * 12 + (endMonth - month); + if (endDay < day) months -= 1; + return Math.max(0, months); +} + +function NowCard({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: string }) { + const current = periods.find((period) => period.current && !period.zeroYear); + const antar = current?.antardashas.find((item) => item.current); + const remaining = antar ? dashaRemainingMonths(antar.end, asOf) : null; + return ( +
+

现在

+
+
+
大运
+
{current ? current.label : "未落入已返回的时段"}
+
+
+
小运
+
{antar ? antar.label : "—"}
+
+
+
小运剩余
+
{remaining === null ? "—" : `${remaining} 个月`}
+
+
+
+ ); +} + +function Timeline({ periods, asOf }: { periods: readonly DashaPeriod[]; asOf: string }) { + const visible = periods.filter((period) => !period.zeroYear); + if (visible.length === 0) return null; + const start = visible[0]!.start; + const end = visible[visible.length - 1]!.end; + const startMs = dayMs(start); + const endMs = dayMs(end); + const span = endMs - startMs; + const cursor = span > 0 ? Math.min(1, Math.max(0, (dayMs(asOf) - startMs) / span)) : 0; + return ( +
+
+
+ {visible.map((period) => { + const weight = span > 0 ? (dayMs(period.end) - dayMs(period.start)) / span : 1 / visible.length; + return ( + + ); + })} +
+ +
+
+ {formatYearMonth(start)} + {formatYearMonth(end)} +
+
+ ); +} + function Track({ title, method, - currentLabel, antardashaNote, periods, -}: ChartViewOk["dasha"]["vimshottari"]) { + asOf, +}: DashaTrack & { asOf: string }) { return (

{title}

- {/* Both tracks ship a `method` equal to their own title, which rendered - the name twice; only show it when it actually adds something. */} {method && method !== title ?

{method}

: null} - {/* No space, and no second 当前: the fallback labels used to start with - it themselves, which read as 「当前 当前 Chara 段…」. */} -

当前{currentLabel}

+ +
    - {periods.map((period) => ( -
  1. -

    - {period.label} - {period.zeroYear ? CHARA_ZERO_YEAR_LABEL : `${period.start} – ${period.end}`} -

    - {period.zeroYear ? ( + {periods.map((period, index) => ( + period.zeroYear ? ( +
  2. +

    + {period.label} + {CHARA_ZERO_YEAR_LABEL} +

    {CHARA_ZERO_YEAR_NOTE}

    - ) : period.antardashas.length > 0 ? ( -
      - {period.antardashas.map((antar) => ( -
    • - {antar.label} · {antar.start} – {antar.end} -
    • - ))} -
    - ) : null} -
  3. + + ) : ( +
  4. +
    + + {period.label} + {formatYearMonth(period.start)} – {formatYearMonth(period.end)} + + {period.antardashas.length > 0 ? ( +
      + {period.antardashas.map((antar) => ( +
    • + {antar.label} · + {formatYearMonth(antar.start)} – {formatYearMonth(antar.end)} +
    • + ))} +
    + ) : null} +
    +
  5. + ) ))}
{antardashaNote ?

{antardashaNote}

: null} @@ -58,16 +147,17 @@ export function ChartDashaTab({ charaPending?: boolean; charaFailure?: ChartViewFailureStatus; }) { + const asOf = view.dasha.asOf; return (
- + {charaPending || charaFailure ? (

{charaPending ? CHART_VIEW_COPY.waitingLayer : chartLayerFailureCopy(charaFailure!)}

) : ( - + )}

{view.dasha.dualTrackNote}

diff --git a/frontend/src/components/chart-page/chart-western-tab.tsx b/frontend/src/components/chart-page/chart-western-tab.tsx index 944fc77b..6d764e60 100644 --- a/frontend/src/components/chart-page/chart-western-tab.tsx +++ b/frontend/src/components/chart-page/chart-western-tab.tsx @@ -1,9 +1,57 @@ -import type { ChartViewOk } from "@/lib/chart-view-contract"; -import { COORDINATE_BOUNDARY } from "@/lib/chart-view-labels"; +"use client"; + +import { useState } from "react"; + +import type { ChartViewOk, ChartViewWesternOk } from "@/lib/chart-view-contract"; +import { COORDINATE_BOUNDARY, SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; +import { formatDegreeMinutes } from "./chart-format"; import { WesternWheelSvg } from "./western-wheel-svg"; -export function ChartWesternTab({ view }: { view: ChartViewOk }) { +const STELLIUM_COUNT = 3; + +type WesternAspect = ChartViewWesternOk["aspects"][number]; + +export function westernAspectsForLabel(aspects: readonly WesternAspect[], label: string | null): WesternAspect[] { + if (!label) return [...aspects]; + return aspects.filter((aspect) => aspect.left === label || aspect.right === label); +} + +function signAt(longitude: number): string { + const index = Math.floor((((longitude % 360) + 360) % 360) / 30) % 12; + return signZh(SIGN_ORDER[index] ?? "Aries"); +} + +function degreeInSign(longitude: number): number { + return ((longitude % 360) + 360) % 360 % 30; +} + +function dominantElementLabels(elements: ChartViewWesternOk["elements"]): string[] { + const max = elements.reduce((best, item) => Math.max(best, item.count), 0); + if (max <= 0) return []; + return elements.filter((item) => item.count === max).map((item) => item.label); +} + +function stelliumLabels(planets: ChartViewWesternOk["planets"]): string[] { + const counts = new Map(); + for (const planet of planets) { + counts.set(planet.signLabel, (counts.get(planet.signLabel) ?? 0) + 1); + } + return [...counts.entries()].filter(([, count]) => count >= STELLIUM_COUNT).map(([label]) => label); +} + +function houseSystemLabel(houseSystem: string): string { + return houseSystem === "P" ? "Placidus" : houseSystem; +} + +export function ChartWesternTab({ + view, + initialSelectedId = null, +}: { + view: ChartViewOk; + initialSelectedId?: string | null; +}) { const western = view.western; + const [selectedId, setSelectedId] = useState(initialSelectedId); if (western.status === "unavailable") { return (
@@ -12,42 +60,107 @@ export function ChartWesternTab({ view }: { view: ChartViewOk }) {
); } + const selected = western.planets.find((planet) => planet.id === selectedId) ?? null; + const aspects = westernAspectsForLabel(western.aspects, selected?.label ?? null); + const elements = dominantElementLabels(western.elements); + const stelliums = stelliumLabels(western.planets); + const sun = western.planets.find((planet) => planet.id === "sun"); + const moon = western.planets.find((planet) => planet.id === "moon"); + + function toggle(id: string) { + setSelectedId((current) => (current === id ? null : id)); + } + return (
- +
+ +
-

{western.boundary}

-

{western.ayanamsaGapNote}

+
    +
  • 上升 {signAt(western.ascendantLongitude)} {formatDegreeMinutes(degreeInSign(western.ascendantLongitude))}
  • + {sun ?
  • 太阳 {sun.signLabel} {formatDegreeMinutes(sun.degreeInSign)}
  • : null} + {moon ?
  • 月亮 {moon.signLabel} {formatDegreeMinutes(moon.degreeInSign)}
  • : null} + {elements.length > 0 ?
  • 元素最多 {elements.join(" · ")}
  • : null} + {stelliums.length > 0 ?
  • 星群 {stelliums.join(" · ")}
  • : null} +
-

元素 / 模式

-

{western.elements.map((item) => `${item.label} ${item.count}`).join(" · ")}

-

{western.modes.map((item) => `${item.label} ${item.count}`).join(" · ")}

-
-
-

主要相位

-
- +

行星位置

+
+
+ + + + + + + + {western.planets.map((planet) => { + const selectedRow = selected?.id === planet.id; + return ( + toggle(planet.id)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggle(planet.id); + }} + > + + + + + + + ); + })} + +
星体星座度数宫位逆行
{planet.label}{planet.signLabel}{formatDegreeMinutes(planet.degreeInSign)}{planet.house ?? "—"}{planet.retrograde ? "逆" : "顺"}
+
+
+
+

主要相位

+
+ + + + - {western.aspects.slice(0, 12).map((aspect) => ( - + {aspects.length === 0 ? ( + + + + ) : aspects.map((aspect) => ( + - + ))}
星体对 相位 容许度
没有主要相位
{aspect.left} · {aspect.right} {aspect.aspectLabel}{aspect.orb.toFixed(1)}°{aspect.orb.toFixed(1)}°
-

宫制 {western.houseSystem === "P" ? "Placidus" : western.houseSystem}

+

{western.boundary}

+

{western.ayanamsaGapNote}

+

宫制 {houseSystemLabel(western.houseSystem)}

diff --git a/frontend/src/components/chart-page/western-wheel-layout.ts b/frontend/src/components/chart-page/western-wheel-layout.ts new file mode 100644 index 00000000..f82365d8 --- /dev/null +++ b/frontend/src/components/chart-page/western-wheel-layout.ts @@ -0,0 +1,260 @@ +/** Screen geometry for the tropical wheel. Angles stay in the existing + * orientation: the ascendant is on the left, and longitude increases clockwise. + */ + +export const WESTERN_SIZE = 520; +export const WESTERN_CX = 260; +export const WESTERN_CY = 260; +export const WESTERN_R_OUTER = 252; +export const WESTERN_R_ZODIAC = 228; +export const WESTERN_R_PLANET = 188; +export const WESTERN_R_PLANET_INNER = 136; +export const WESTERN_R_HOUSE = 112; +export const WESTERN_R_NUMBER = 84; +export const WESTERN_R_ASPECT = 62; +export const WESTERN_R_ANGLE = 162; +export const WESTERN_FONT_SIZE = 17; +export const WESTERN_MIN_SEPARATION_DEG = 11; + +const TEXT_PRESENTATION = "\uFE0E"; + +const WESTERN_GLYPH: Record = { + sun: "☉", + moon: "☽", + mercury: "☿", + venus: `♀${TEXT_PRESENTATION}`, + mars: `♂${TEXT_PRESENTATION}`, + jupiter: "♃", + saturn: "♄", + uranus: "♅", + neptune: "♆", + pluto: "♇", + true_node: "☊", +}; + +export type WesternLayoutBody = { + id: string; + longitude: number; + caption: string; +}; + +export type WesternLayoutResult = { + id: string; + longitude: number; + displayLongitude: number; + tier: 0 | 1; +}; + +export type WesternLabelBox = { + left: number; + top: number; + right: number; + bottom: number; +}; + +export function normalizeDegrees(value: number): number { + return ((value % 360) + 360) % 360; +} + +export function westernScreenAngle(longitude: number, ascendantLongitude: number): number { + return 180 - (longitude - ascendantLongitude); +} + +export function westernPolar(radius: number, screenDeg: number): { x: number; y: number } { + const rad = (screenDeg * Math.PI) / 180; + return { + x: WESTERN_CX + radius * Math.cos(rad), + y: WESTERN_CY - radius * Math.sin(rad), + }; +} + +/** Forward midpoint from one cusp to the next, not the cusp itself. */ +export function houseMidLongitude(cusp: number, nextCusp: number): number { + const start = normalizeDegrees(cusp); + const span = (normalizeDegrees(nextCusp) - start + 360) % 360; + return normalizeDegrees(start + span / 2); +} + +export function westernPlanetCaption(planet: { + id: string; + label: string; + degreeInSign: number; + retrograde: boolean; +}): string { + const glyph = WESTERN_GLYPH[planet.id] ?? planet.label; + const whole = Math.floor(Math.min(29.9999, Math.max(0, planet.degreeInSign))); + return `${glyph}${whole}°${planet.retrograde ? "℞" : ""}`; +} + +export function westernTextWidth(text: string, fontSize = WESTERN_FONT_SIZE): number { + let width = 0; + for (const char of text) { + const code = char.codePointAt(0) ?? 0; + if (code === 0xfe0e || code === 0xfe0f) continue; + width += code > 0xff ? fontSize : fontSize * 0.62; + } + return width; +} + +export function westernLabelBox( + x: number, + y: number, + text: string, + fontSize = WESTERN_FONT_SIZE, +): WesternLabelBox { + const width = westernTextWidth(text, fontSize); + const height = fontSize; + return { + left: x - width / 2, + top: y - height / 2, + right: x + width / 2, + bottom: y + height / 2, + }; +} + +export function westernLabelInsideViewBox(box: WesternLabelBox, size = WESTERN_SIZE): boolean { + return box.left >= 0 && box.top >= 0 && box.right <= size && box.bottom <= size; +} + +function boxesOverlap(a: WesternLabelBox, b: WesternLabelBox): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +function relax(original: number[]): number[] { + const count = original.length; + const pos = original.slice(); + const min = WESTERN_MIN_SEPARATION_DEG; + for (let iter = 0; iter < 8; iter += 1) { + for (let index = 1; index < count; index += 1) { + const floor = pos[index - 1]! + min; + if (pos[index]! < floor) pos[index] = floor; + } + const limit = pos[0]! + 360 - min; + if (pos[count - 1]! > limit) { + const overflow = pos[count - 1]! - limit; + for (let index = 0; index < count; index += 1) pos[index] = pos[index]! - overflow; + for (let index = count - 2; index >= 0; index -= 1) { + const ceil = pos[index + 1]! - min; + if (pos[index]! > ceil) pos[index] = ceil; + } + } + for (let index = 0; index < count; index += 1) { + const lo = index === 0 ? pos[count - 1]! - 360 + min : pos[index - 1]! + min; + const hi = index === count - 1 ? pos[0]! + 360 - min : pos[index + 1]! - min; + if (hi < lo) continue; + pos[index] = Math.min(hi, Math.max(lo, original[index]!)); + } + } + return pos; +} + +function separateSorted(sorted: readonly number[]): number[] { + const count = sorted.length; + if (count === 0) return []; + if (count === 1) return [normalizeDegrees(sorted[0]!)]; + let cut = 0; + let largest = -1; + for (let index = 0; index < count; index += 1) { + const gap = (sorted[(index + 1) % count]! - sorted[index]! + 360) % 360; + if (gap > largest) { + largest = gap; + cut = (index + 1) % count; + } + } + const order = Array.from({ length: count }, (_, step) => (cut + step) % count); + const original: number[] = []; + for (let step = 0; step < count; step += 1) { + const longitude = sorted[order[step]!]!; + if (step === 0) original.push(longitude); + else { + let next = longitude; + while (next < original[step - 1]!) next += 360; + original.push(next); + } + } + const placed = relax(original); + const display = sorted.slice(); + for (let step = 0; step < count; step += 1) display[order[step]!] = normalizeDegrees(placed[step]!); + return display; +} + +function planetPoint(displayLongitude: number, ascendantLongitude: number, tier: 0 | 1) { + const radius = tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER; + return westernPolar(radius, westernScreenAngle(displayLongitude, ascendantLongitude)); +} + +/** + * Push planet captions apart along the ecliptic until adjacent display angles + * are at least 11°. A second radius is used only when the primary radius would + * still overlap; the font size stays at 17. + */ +export function layoutWesternLabels( + planets: readonly WesternLayoutBody[], + ascendantLongitude = 0, +): WesternLayoutResult[] { + if (planets.length === 0) return []; + const indexed = planets.map((planet, index) => ({ + planet, + index, + longitude: normalizeDegrees(planet.longitude), + })); + indexed.sort((a, b) => a.longitude - b.longitude || a.planet.id.localeCompare(b.planet.id)); + const display = separateSorted(indexed.map((item) => item.longitude)); + const byDisplay = indexed + .map((_, index) => index) + .sort((a, b) => display[a]! - display[b]! || indexed[a]!.planet.id.localeCompare(indexed[b]!.planet.id)); + const overlapCount = (tiers: readonly (0 | 1)[]) => { + const boxes = indexed.map((item, index) => { + const point = planetPoint(display[index]!, ascendantLongitude, tiers[index]!); + return westernLabelBox(point.x, point.y, item.planet.caption); + }); + let count = 0; + for (let left = 0; left < boxes.length; left += 1) { + for (let right = left + 1; right < boxes.length; right += 1) { + if (boxesOverlap(boxes[left]!, boxes[right]!)) count += 1; + } + } + return count; + }; + const alternate = (phase: 0 | 1) => { + const tiers = new Array<0 | 1>(indexed.length).fill(0); + byDisplay.forEach((sortedIndex, order) => { + tiers[sortedIndex] = ((order + phase) % 2) as 0 | 1; + }); + return tiers; + }; + const flat = new Array<0 | 1>(indexed.length).fill(0); + let tiers = flat; + if (overlapCount(flat) > 0) { + const primary = alternate(0); + const secondary = alternate(1); + tiers = overlapCount(secondary) < overlapCount(primary) ? secondary : primary; + } + const results: WesternLayoutResult[] = new Array(planets.length); + indexed.forEach((item, index) => { + results[item.index] = { + id: item.planet.id, + longitude: item.longitude, + displayLongitude: display[index]!, + tier: tiers[index]!, + }; + }); + return results; +} + +export function westernRingSlice(inner: number, outer: number, startDeg: number, endDeg: number): string { + const sweep = ((endDeg - startDeg) % 360 + 360) % 360; + const large = sweep > 180 ? 1 : 0; + const outerStart = westernPolar(outer, startDeg); + const outerEnd = westernPolar(outer, startDeg + sweep); + const innerEnd = westernPolar(inner, startDeg + sweep); + const innerStart = westernPolar(inner, startDeg); + const fmt = (value: number) => value.toFixed(2); + return [ + `M ${fmt(outerStart.x)} ${fmt(outerStart.y)}`, + `A ${outer} ${outer} 0 ${large} 0 ${fmt(outerEnd.x)} ${fmt(outerEnd.y)}`, + `L ${fmt(innerEnd.x)} ${fmt(innerEnd.y)}`, + `A ${inner} ${inner} 0 ${large} 1 ${fmt(innerStart.x)} ${fmt(innerStart.y)}`, + "Z", + ].join(" "); +} diff --git a/frontend/src/components/chart-page/western-wheel-svg.tsx b/frontend/src/components/chart-page/western-wheel-svg.tsx index b22ab824..66358e76 100644 --- a/frontend/src/components/chart-page/western-wheel-svg.tsx +++ b/frontend/src/components/chart-page/western-wheel-svg.tsx @@ -1,84 +1,263 @@ import type { ChartViewWesternOk } from "@/lib/chart-view-contract"; -import { SIGN_ORDER, signZh } from "@/lib/chart-view-labels"; +import { SIGN_ORDER, signZh, type SignName } from "@/lib/chart-view-labels"; +import { + WESTERN_CX, + WESTERN_CY, + WESTERN_R_ANGLE, + WESTERN_R_ASPECT, + WESTERN_R_HOUSE, + WESTERN_R_NUMBER, + WESTERN_R_OUTER, + WESTERN_R_PLANET, + WESTERN_R_PLANET_INNER, + WESTERN_R_ZODIAC, + WESTERN_SIZE, + houseMidLongitude, + layoutWesternLabels, + westernPlanetCaption, + westernPolar, + westernRingSlice, + westernScreenAngle, +} from "./western-wheel-layout"; -const SIZE = 400; -const CX = SIZE / 2; -const CY = SIZE / 2; -const OUTER = 188; -const ZODIAC = 168; -const HOUSE = 128; -const INNER = 78; +const SIGN_ELEMENT: Record = { + Aries: "fire", + Taurus: "earth", + Gemini: "air", + Cancer: "water", + Leo: "fire", + Virgo: "earth", + Libra: "air", + Scorpio: "water", + Sagittarius: "fire", + Capricorn: "earth", + Aquarius: "air", + Pisces: "water", +}; -function polar(radius: number, deg: number): { x: number; y: number } { - const rad = (deg * Math.PI) / 180; - return { x: CX + radius * Math.cos(rad), y: CY - radius * Math.sin(rad) }; +const SOFT_ASPECTS = new Set(["conjunction", "sextile", "trine"]); +const HARD_ASPECTS = new Set(["square", "opposition"]); + +type WesternWheelModel = Pick; + +export const EMPTY_WESTERN_WHEEL: WesternWheelModel = { + ascendantLongitude: 0, + houses: [], + planets: [], + aspects: [], +}; + +function fmt(value: number): string { + return value.toFixed(2); } -function screenAngle(longitude: number, ascendant: number): number { - return 180 - (longitude - ascendant); +function pointToward( + from: { x: number; y: number }, + to: { x: number; y: number }, + stop: number, +): { x: number; y: number } { + const dx = to.x - from.x; + const dy = to.y - from.y; + const dist = Math.hypot(dx, dy) || 1; + const travel = Math.max(0, dist - stop); + return { x: from.x + (dx / dist) * travel, y: from.y + (dy / dist) * travel }; } -type WesternWheelModel = Pick; -export const EMPTY_WESTERN_WHEEL: WesternWheelModel = { ascendantLongitude: 0, houses: [], planets: [] }; +function aspectTone(aspect: string): "soft" | "hard" | "other" { + if (SOFT_ASPECTS.has(aspect)) return "soft"; + if (HARD_ASPECTS.has(aspect)) return "hard"; + return "other"; +} -export function WesternWheelSvg({ western: suppliedWestern, skeleton = false }: { western: WesternWheelModel; skeleton?: boolean }) { +export function WesternWheelSvg({ + western: suppliedWestern, + skeleton = false, + selectedLabel = null, + onSelectPlanet, +}: { + western: WesternWheelModel; + skeleton?: boolean; + selectedLabel?: string | null; + onSelectPlanet?: (id: string) => void; +}) { const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern; const asc = western.ascendantLongitude; - const signWedges = SIGN_ORDER.map((sign, index) => { - const mid = screenAngle(index * 30 + 15, asc); - return { sign, label: polar((ZODIAC + OUTER) / 2, mid) }; - }); + const placements = layoutWesternLabels( + western.planets.map((planet) => ({ + id: planet.id, + longitude: planet.longitude, + caption: westernPlanetCaption(planet), + })), + asc, + ); + const placementById = new Map(placements.map((item) => [item.id, item])); + const longitudeByLabel = new Map(western.planets.map((planet) => [planet.label, planet.longitude])); + longitudeByLabel.set("上升", asc); + const tenth = western.houses.find((house) => house.house === 10); + if (tenth) longitudeByLabel.set("中天", tenth.longitude); + return ( - - - - - {signWedges.map((wedge) => { - const start = polar(ZODIAC, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc)); - const outer = polar(OUTER, screenAngle(SIGN_ORDER.indexOf(wedge.sign) * 30, asc)); + {!skeleton && SIGN_ORDER.map((sign, index) => { + const start = westernScreenAngle(index * 30 + 30, asc); + const end = westernScreenAngle(index * 30, asc); return ( - - - {!skeleton && - {signZh(wedge.sign)} - } + + ); + })} + + + + {SIGN_ORDER.map((sign, index) => { + const edge = westernScreenAngle(index * 30, asc); + const inner = westernPolar(WESTERN_R_ZODIAC, edge); + const outer = westernPolar(WESTERN_R_OUTER, edge); + const label = westernPolar((WESTERN_R_ZODIAC + WESTERN_R_OUTER) / 2, westernScreenAngle(index * 30 + 15, asc)); + return ( + + + {!skeleton && ( + + {signZh(sign)} + + )} ); })} - {western.houses.map((house) => { - const angle = screenAngle(house.longitude, asc); - const inner = polar(INNER, angle); - const outer = polar(ZODIAC, angle); - const label = polar((INNER + HOUSE) / 2, angle); + {!skeleton && western.houses.map((house, index) => { + const next = western.houses[(index + 1) % western.houses.length] ?? house; + const angle = westernScreenAngle(house.longitude, asc); + const inner = westernPolar(WESTERN_R_ASPECT, angle); + const outer = westernPolar(WESTERN_R_ZODIAC, angle); + const mid = houseMidLongitude(house.longitude, next.longitude); + const label = westernPolar(WESTERN_R_NUMBER, westernScreenAngle(mid, asc)); + const axis = house.house === 1 || house.house === 4 || house.house === 7 || house.house === 10; return ( - - + + {house.house} ); })} - {western.planets.map((planet) => { - const point = polar((HOUSE + INNER) / 2 + 10, screenAngle(planet.longitude, asc)); + {!skeleton && western.aspects.map((aspect) => { + const left = longitudeByLabel.get(aspect.left); + const right = longitudeByLabel.get(aspect.right); + if (left === undefined || right === undefined) return null; + const from = westernPolar(WESTERN_R_ASPECT, westernScreenAngle(left, asc)); + const to = westernPolar(WESTERN_R_ASPECT, westernScreenAngle(right, asc)); + const active = selectedLabel !== null && (aspect.left === selectedLabel || aspect.right === selectedLabel); return ( - - {planet.label} - + ); })} - {!skeleton && - 升 - } + {!skeleton && western.planets.map((planet) => { + const placement = placementById.get(planet.id); + if (!placement) return null; + const radius = placement.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER; + const display = westernPolar(radius, westernScreenAngle(placement.displayLongitude, asc)); + const truth = westernPolar(WESTERN_R_ZODIAC - 8, westernScreenAngle(placement.longitude, asc)); + const leaderEnd = pointToward(truth, display, 14); + const selected = selectedLabel === planet.label; + const caption = westernPlanetCaption(planet); + return ( + onSelectPlanet?.(planet.id)} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onSelectPlanet?.(planet.id); + }} + > + + + + + {caption} + + + ); + })} + {!skeleton && ( + + ASC + + )} + {!skeleton && tenth && ( + + MC + + )} ); } diff --git a/frontend/src/lib/chart-view-contract.ts b/frontend/src/lib/chart-view-contract.ts index 24d76e39..5c262bbb 100644 --- a/frontend/src/lib/chart-view-contract.ts +++ b/frontend/src/lib/chart-view-contract.ts @@ -213,6 +213,9 @@ export const chartViewOkSchema = z.object({ planets: z.array(chartViewPlanetRowSchema).min(1), }), dasha: z.object({ + // The day the current flags were computed against. The dasha tab uses it + // for the "现在" card and the timeline cursor. It is not an engine field. + asOf: isoDate, vimshottari: chartViewDashaTrackSchema, chara: chartViewDashaTrackSchema, dualTrackNote: z.string().min(1), diff --git a/frontend/src/lib/chart-view-mapper.ts b/frontend/src/lib/chart-view-mapper.ts index 603209b2..52487d9a 100644 --- a/frontend/src/lib/chart-view-mapper.ts +++ b/frontend/src/lib/chart-view-mapper.ts @@ -695,6 +695,7 @@ export function buildChartView(bundle: ChartViewEngineBundle): ChartViewOk { planets: planetRows(chart, varga), }, dasha: { + asOf: isoDay(asOf), vimshottari, chara: charaMapped, dualTrackNote: DASHA_DUAL_TRACK_NOTE, diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx index e6837416..96790077 100644 --- a/frontend/tests/chart-page-view.test.tsx +++ b/frontend/tests/chart-page-view.test.tsx @@ -4,7 +4,9 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; +import { ChartDashaTab } from "../src/components/chart-page/chart-dasha-tab.tsx"; import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { ChartWesternTab } from "../src/components/chart-page/chart-western-tab.tsx"; import { EMPTY_NORTH_INDIAN_CHART, VedicChartSvg } from "../src/components/personal-report/vedic-chart-svg.tsx"; import { ChartPageView } from "../src/components/chart-page/chart-page-view.tsx"; import { SidebarProvider } from "../src/components/ui/sidebar.tsx"; @@ -14,6 +16,7 @@ import { CHART_VIEW_LAYERS } from "../src/lib/chart-view-engine.ts"; import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts"; import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts"; import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts"; +import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts"; import { cssDeclarations } from "./css-contract-test-support.ts"; Object.assign(globalThis, { React }); @@ -370,6 +373,64 @@ test("chart svgs omit the invalid height attribute and western height stays in c assert.match(globalStyles, /\.chart-page-western-svg \{[^}]*height: auto/); }); +test("the dasha tab folds every track to the current period and keeps a now card", async () => { + const view = await okView(golden.western); + const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view })); + assert.equal((markup.match(/chart-page-dasha-now/g) ?? []).length, 2); + assert.match(markup, /现在/); + assert.match(markup, /
小运剩余<\/dt>
\d+ 个月<\/dd>/); + const periods = [...view.dasha.vimshottari.periods, ...view.dasha.chara.periods]; + assert.equal(periods.some((period) => period.zeroYear), false); + assert.equal((markup.match(/data-dasha-segment=/g) ?? []).length, periods.length); + const details = markup.match(/
period.current).length); + assert.ok(details.length > open.length); + assert.match(cssDeclarations(".chart-page-dasha-period summary", globalStyles), /min-height:\s*44px/); + assert.match(globalStyles, /\.chart-page-dasha-period details:not\(\[open\]\) > ul \{ display: none; \}/); +}); + +test("the western tab lists every planet and filters aspects for the selected body", async () => { + const view = await okView(golden.western); + assert.equal(view.western.status, "ok"); + if (view.western.status !== "ok") return; + const markup = renderToStaticMarkup(React.createElement(ChartWesternTab, { view })); + assert.equal((markup.match(/data-planet-row=/g) ?? []).length, view.western.planets.length); + assert.equal((markup.match(/data-aspect-row=/g) ?? []).length, view.western.aspects.length); + assert.match(markup, /viewBox="0 0 520 520"/); + assert.match(markup, /data-tone=/); + assert.doesNotMatch(markup, /]*>升<\/text>/); + assert.match(markup, /上升 /); + assert.match(markup, /太阳 /); + assert.match(markup, /月亮 /); + assert.match(markup, /星群 摩羯/); + assert.match(cssDeclarations(".chart-page-western-wheel", globalStyles), /max-width:\s*520px/); + assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /overflow-x:\s*auto/); + assert.match(cssDeclarations(".chart-page-western-table-wrap", globalStyles), /min-width:\s*0/); + assert.doesNotMatch(cssDeclarations(".chart-page-western-table", globalStyles), /min-width/); + const sun = view.western.planets.find((planet) => planet.id === "sun"); + assert.ok(sun); + const filtered = renderToStaticMarkup(React.createElement(ChartWesternTab, { view, initialSelectedId: sun.id })); + const expected = view.western.aspects.filter((aspect) => aspect.left === sun.label || aspect.right === sun.label); + assert.equal((filtered.match(/data-aspect-row=/g) ?? []).length, expected.length); + assert.ok(expected.length < view.western.aspects.length); + assert.match(filtered, /data-filtering="true"/); + assert.match(filtered, /is-selected/); + const svg = markup.slice(markup.indexOf("")); + const labels = [...svg.matchAll(/]*>([^<]*)<\/text>/g)].map((match) => ({ + text: match[3] ?? "", + box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""), + })); + for (let left = 0; left < labels.length; left += 1) { + for (let right = left + 1; right < labels.length; right += 1) { + const a = labels[left]!.box; + const b = labels[right]!.box; + const overlap = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + assert.equal(overlap, false, `${labels[left]!.text} overlaps ${labels[right]!.text}`); + } + } +}); + test("the (app) layout mounts one sidebar for home and the four secondary routes", () => { // 原值:`(secondary)/layout.tsx` 挂只读侧栏,`useSidebarData` 另拉一份列表 // 新值:`(app)/layout.tsx` 一份 SessionListProvider + SidebarProvider,首页也在里面 diff --git a/frontend/tests/chart-view-zero-year.test.ts b/frontend/tests/chart-view-zero-year.test.ts index 1c65f7f5..d2c460c4 100644 --- a/frontend/tests/chart-view-zero-year.test.ts +++ b/frontend/tests/chart-view-zero-year.test.ts @@ -95,6 +95,22 @@ test("zero antardasha weights on a positive mahadasha do not produce an invalid } }); +test("a zero-year period is grey, closed, and absent from the timeline", () => { + const view = viewFrom(golden.chara); + const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view })); + const zero = markup.split(" item.includes(CHARA_ZERO_YEAR_LABEL) && item.includes("射手")); + assert.ok(zero); + assert.match(zero, /is-zero/); + assert.doesNotMatch(zero, /
!period.zeroYear).length); + assert.equal(charaSegments.length, 11); + assert.doesNotMatch(tracks[1] ?? "", /]*open[^>]*>[\s\S]*0 年/); +}); + test("the zero-year Chara row shows 0 年 and does not render sub-periods", () => { const view = viewFrom(golden.chara); const markup = renderToStaticMarkup(React.createElement(ChartDashaTab, { view })); diff --git a/frontend/tests/fixtures/western-1990-01-01-beijing.json b/frontend/tests/fixtures/western-1990-01-01-beijing.json new file mode 100644 index 00000000..adb7eb22 --- /dev/null +++ b/frontend/tests/fixtures/western-1990-01-01-beijing.json @@ -0,0 +1,33 @@ +{ + "source": "scripts.western_chart_engine.build_tropical_natal_chart", + "birth": "1990-01-01 12:00 timezone 8 latitude 39.9042 longitude 116.4074 house P", + "ascendant": { "longitude": 11.837301, "sign": "Aries", "degree_in_sign": 11.837301 }, + "mc": { "longitude": 276.388945, "sign": "Capricorn", "degree_in_sign": 6.388945 }, + "planets": [ + { "id": "sun", "label": "太阳", "longitude": 280.474427, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 10.474427, "house": 10, "retrograde": false }, + { "id": "moon", "label": "月亮", "longitude": 328.788668, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 28.788668, "house": 12, "retrograde": false }, + { "id": "mercury", "label": "水星", "longitude": 295.760024, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 25.760024, "house": 10, "retrograde": true }, + { "id": "venus", "label": "金星", "longitude": 306.262927, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 6.262927, "house": 11, "retrograde": true }, + { "id": "mars", "label": "火星", "longitude": 249.765531, "sign": "Sagittarius", "signLabel": "射手", "degreeInSign": 9.765531, "house": 8, "retrograde": false }, + { "id": "jupiter", "label": "木星", "longitude": 95.193582, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 5.193582, "house": 3, "retrograde": true }, + { "id": "saturn", "label": "土星", "longitude": 285.618103, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 15.618103, "house": 10, "retrograde": false }, + { "id": "uranus", "label": "天王", "longitude": 275.765468, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 5.765468, "house": 9, "retrograde": false }, + { "id": "neptune", "label": "海王", "longitude": 282.025482, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 12.025482, "house": 10, "retrograde": false }, + { "id": "pluto", "label": "冥王", "longitude": 227.084033, "sign": "Scorpio", "signLabel": "天蝎", "degreeInSign": 17.084033, "house": 7, "retrograde": false }, + { "id": "true_node", "label": "北交", "longitude": 316.861653, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 16.861653, "house": 11, "retrograde": false } + ], + "houses": [ + { "house": 1, "longitude": 11.837301, "sign": "Aries", "signLabel": "白羊", "degreeInSign": 11.837301 }, + { "house": 2, "longitude": 49.703156, "sign": "Taurus", "signLabel": "金牛", "degreeInSign": 19.703156 }, + { "house": 3, "longitude": 75.027826, "sign": "Gemini", "signLabel": "双子", "degreeInSign": 15.027826 }, + { "house": 4, "longitude": 96.388945, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 6.388945 }, + { "house": 5, "longitude": 118.786626, "sign": "Cancer", "signLabel": "巨蟹", "degreeInSign": 28.786626 }, + { "house": 6, "longitude": 147.810722, "sign": "Leo", "signLabel": "狮子", "degreeInSign": 27.810722 }, + { "house": 7, "longitude": 191.837301, "sign": "Libra", "signLabel": "天秤", "degreeInSign": 11.837301 }, + { "house": 8, "longitude": 229.703156, "sign": "Scorpio", "signLabel": "天蝎", "degreeInSign": 19.703156 }, + { "house": 9, "longitude": 255.027826, "sign": "Sagittarius", "signLabel": "射手", "degreeInSign": 15.027826 }, + { "house": 10, "longitude": 276.388945, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 6.388945 }, + { "house": 11, "longitude": 298.786626, "sign": "Capricorn", "signLabel": "摩羯", "degreeInSign": 28.786626 }, + { "house": 12, "longitude": 327.810722, "sign": "Aquarius", "signLabel": "水瓶", "degreeInSign": 27.810722 } + ] +} diff --git a/frontend/tests/western-wheel-layout.test.ts b/frontend/tests/western-wheel-layout.test.ts new file mode 100644 index 00000000..245603c4 --- /dev/null +++ b/frontend/tests/western-wheel-layout.test.ts @@ -0,0 +1,155 @@ +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 { + WESTERN_CX, + WESTERN_CY, + WESTERN_MIN_SEPARATION_DEG, + WESTERN_R_NUMBER, + WESTERN_R_PLANET, + WESTERN_R_PLANET_INNER, + WESTERN_SIZE, + layoutWesternLabels, + westernLabelBox, + westernLabelInsideViewBox, + westernPlanetCaption, + westernPolar, + westernScreenAngle, + type WesternLayoutBody, +} from "../src/components/chart-page/western-wheel-layout.ts"; +import { EMPTY_WESTERN_WHEEL, WesternWheelSvg } from "../src/components/chart-page/western-wheel-svg.tsx"; + +Object.assign(globalThis, { React }); + +const fixture = JSON.parse( + readFileSync(new URL("./fixtures/western-1990-01-01-beijing.json", import.meta.url), "utf8"), +) as { + ascendant: { longitude: number }; + planets: Array; + houses: Array<{ house: number; longitude: number; sign: string; signLabel: string; degreeInSign: number }>; +}; + +function normalize(value: number): number { + return ((value % 360) + 360) % 360; +} + +function forwardGap(from: number, to: number): number { + return (to - from + 360) % 360; +} + +function longitudeFromPoint(x: number, y: number, ascendant: number): number { + const screen = (Math.atan2(WESTERN_CY - y, x - WESTERN_CX) * 180) / Math.PI; + return normalize(ascendant + 180 - screen); +} + +function houseMid(cusp: number, next: number): number { + const start = normalize(cusp); + const span = forwardGap(start, normalize(next)); + return normalize(start + span / 2); +} + +function boxesOverlap( + a: { left: number; top: number; right: number; bottom: number }, + b: { left: number; top: number; right: number; bottom: number }, +): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +test("layoutWesternLabels separates the 1990-01-01 Capricorn cluster by at least 11 degrees", () => { + const bodies: WesternLayoutBody[] = fixture.planets.map((planet) => ({ + id: planet.id, + longitude: planet.longitude, + caption: westernPlanetCaption(planet), + })); + const placed = layoutWesternLabels(bodies, fixture.ascendant.longitude); + assert.equal(placed.length, bodies.length); + const sun = fixture.planets.find((planet) => planet.id === "sun"); + const neptune = fixture.planets.find((planet) => planet.id === "neptune"); + assert.ok(sun && neptune); + assert.ok(forwardGap(sun.longitude, neptune.longitude) < WESTERN_MIN_SEPARATION_DEG); + const ordered = [...placed].sort((a, b) => a.displayLongitude - b.displayLongitude); + for (let index = 0; index < ordered.length; index += 1) { + const gap = forwardGap(ordered[index]!.displayLongitude, ordered[(index + 1) % ordered.length]!.displayLongitude); + assert.ok(gap >= WESTERN_MIN_SEPARATION_DEG - 1e-6, `${ordered[index]!.id} gap ${gap}`); + } + const boxes = placed.map((item) => { + const body = bodies.find((planet) => planet.id === item.id)!; + const radius = item.tier === 0 ? WESTERN_R_PLANET : WESTERN_R_PLANET_INNER; + const point = westernPolar(radius, westernScreenAngle(item.displayLongitude, fixture.ascendant.longitude)); + const box = westernLabelBox(point.x, point.y, body.caption); + assert.equal(westernLabelInsideViewBox(box), true, item.id); + return box; + }); + for (let left = 0; left < boxes.length; left += 1) { + for (let right = left + 1; right < boxes.length; right += 1) { + assert.equal(boxesOverlap(boxes[left]!, boxes[right]!), false, `${placed[left]!.id} overlaps ${placed[right]!.id}`); + } + } +}); + +test("house numbers sit at the house midpoint and every label stays inside the viewBox", () => { + const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, { + western: { + ascendantLongitude: fixture.ascendant.longitude, + planets: fixture.planets, + houses: fixture.houses, + aspects: [], + }, + })); + assert.match(markup, /viewBox="0 0 520 520"/); + assert.doesNotMatch(markup, /]*>升<\/text>/); + assert.match(markup, />ASCMC]*data-house="${house.house}"`)); + assert.ok(tag, `missing house ${house.house}`); + const longitude = longitudeFromPoint(Number(tag[1]), Number(tag[2]), ascendant); + const radius = Math.hypot(Number(tag[1]) - WESTERN_CX, Number(tag[2]) - WESTERN_CY); + assert.ok(Math.abs(radius - WESTERN_R_NUMBER) < 0.2); + const delta = Math.min(forwardGap(expected, longitude), forwardGap(longitude, expected)); + assert.ok(delta < 0.05, `house ${house.house} is ${longitude}, expected ${expected}`); + const cuspDelta = Math.min(forwardGap(house.longitude, longitude), forwardGap(longitude, house.longitude)); + assert.ok(cuspDelta > 1, `house ${house.house} sits on its cusp`); + } + const labels = [...markup.matchAll(/]*>([^<]*)<\/text>/g)].map((match) => ({ + text: match[3] ?? "", + box: westernLabelBox(Number(match[1]), Number(match[2]), match[3] ?? ""), + })); + for (const label of labels) { + assert.equal(westernLabelInsideViewBox(label.box, WESTERN_SIZE), true, label.text); + } + for (let left = 0; left < labels.length; left += 1) { + for (let right = left + 1; right < labels.length; right += 1) { + assert.equal( + boxesOverlap(labels[left]!.box, labels[right]!.box), + false, + `${labels[left]!.text} overlaps ${labels[right]!.text}`, + ); + } + } +}); + +test("the western skeleton draws three rings and spokes without labels or aspects", () => { + const markup = renderToStaticMarkup(React.createElement(WesternWheelSvg, { + western: EMPTY_WESTERN_WHEEL, + skeleton: true, + })); + assert.match(markup, /data-skeleton="true"/); + assert.equal((markup.match(/