diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 09e1215a..548a7426 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4962,12 +4962,132 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .chart-page-western-side { min-width: 0; } -.chart-page-varga-chips { +/* Chart-type strip (TASK-chart-types D2): one line that scrolls sideways and + never wraps, with the 「≡」 button pinned at the end outside the scroller. */ +.chart-page-type-bar { display: flex; - flex-wrap: wrap; + align-items: center; gap: var(--space-1); + min-width: 0; margin-bottom: var(--space-4); } +.chart-page-type-strip { + display: flex; + flex: 1 1 auto; + flex-wrap: nowrap; + gap: var(--space-1); + min-width: 0; + overflow-x: auto; + overscroll-behavior-x: contain; + scrollbar-width: none; +} +.chart-page-type-strip::-webkit-scrollbar { display: none; } +.chart-page-type-strip .chart-page-chip { flex: 0 0 auto; white-space: nowrap; } +.chart-page-type-menu { + display: inline-grid; + flex: 0 0 auto; + place-items: center; + width: 44px; + height: 44px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-ink-secondary); + cursor: pointer; +} +.chart-page-type-menu:hover { color: var(--color-ink); background: var(--color-canvas-muted); } +.chart-page-type-menu:focus-visible, +.chart-page-type-cell:focus-visible, +.chart-page-type-sheet-close:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; } +.chart-page-type-menu svg { width: 20px; height: 20px; } +/* The sheet: bottom drawer on phones, centred 720px dialog from 860px — the + same frame as the report's 分块导出 drawer. */ +.chart-page-type-sheet { + position: fixed; + inset: auto 0 0 0; + width: min(760px, 100%); + max-width: none; + max-height: 80vh; + margin: 0 auto; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + background: var(--color-canvas); + color: var(--color-ink); + box-shadow: var(--shadow-elevated); + overflow: hidden; +} +.chart-page-type-sheet::backdrop { background: var(--color-scrim); } +.chart-page-type-sheet-panel { display: flex; flex-direction: column; max-height: 80vh; } +@supports (height: 1dvh) { + .chart-page-type-sheet, .chart-page-type-sheet-panel { max-height: 80dvh; } +} +.chart-page-type-sheet-header { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); +} +.chart-page-type-sheet-header h2 { margin: 0; font-size: var(--type-title-lg); } +.chart-page-type-sheet-close { + display: inline-grid; + place-items: center; + width: 44px; + height: 44px; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-ink-secondary); + cursor: pointer; +} +.chart-page-type-sheet-close svg { width: 20px; height: 20px; } +.chart-page-type-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--space-2); + padding: var(--space-3) var(--space-4) max(var(--space-4), env(safe-area-inset-bottom)); + overflow-y: auto; + overscroll-behavior: contain; +} +.chart-page-type-cell { + display: flex; + min-width: 0; + min-height: 56px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-ink); + cursor: pointer; +} +.chart-page-type-cell strong { font-size: var(--type-body-md); font-weight: 500; font-variant-numeric: tabular-nums; } +.chart-page-type-cell small { + max-width: 100%; + overflow: hidden; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + text-overflow: ellipsis; + white-space: nowrap; +} +.chart-page-type-cell:hover { background: var(--color-canvas-muted); } +.chart-page-type-cell.is-current { border-color: var(--color-ink); background: var(--color-selected); } +@media (min-width: 860px) { + .chart-page-type-sheet { + inset: 0; + width: min(720px, 92vw); + max-height: 80vh; + margin: auto; + border-radius: var(--radius-lg); + } +} +@media print { .chart-page-type-sheet { display: none !important; } } +.chart-page-precision-hint { margin: 0 0 var(--space-2); } .chart-page-waiting-plot { width: min(100%, 400px); min-width: 0; diff --git a/frontend/src/components/chart-page/chart-page-view.tsx b/frontend/src/components/chart-page/chart-page-view.tsx index cfbd5361..b717bba1 100644 --- a/frontend/src/components/chart-page/chart-page-view.tsx +++ b/frontend/src/components/chart-page/chart-page-view.tsx @@ -11,7 +11,7 @@ import { type ChartViewTabId, } from "@/lib/chart-view-contract"; import type { ChartViewLayer } from "@/lib/chart-view-engine"; -import { CHART_VIEW_COPY } from "@/lib/chart-view-labels"; +import { CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels"; import { CHART_SETTINGS_HREF } from "@/lib/settings-url"; import { chartLayerFailureCopy } from "@/lib/chart-view-failure"; import { ChartPageSkeleton } from "./chart-page-skeleton"; @@ -32,6 +32,15 @@ export function ChartPageRoute() { ); } +/** + * The lazy layer a chart type needs. D1 and the Moon chart are projected from + * the natal packet that is already on the page, so they request nothing. + */ +function chartTypeLayer(id: ChartTypeId): ChartViewLayer | null { + if (id === "D1" || id === "MOON" || id === "BHAVA" || id === "TRANSIT") return null; + return "varga"; +} + function WaitingNote({ copy }: { copy: string }) { return

{copy}

; } @@ -63,7 +72,7 @@ export function ChartPageView({ initialTab?: ChartViewTabId; }) { const [tab, setTab] = useState(initialTab); - const [vargaId, setVargaId] = useState("D1"); + const [vargaId, setVargaId] = useState("D1"); function selectTab(id: ChartViewTabId) { setTab(id); @@ -71,9 +80,10 @@ export function ChartPageView({ if (id === "western") onNeedLayer?.("western"); } - function selectVarga(id: string) { + function selectVarga(id: ChartTypeId) { setVargaId(id); - if (id !== "D1") onNeedLayer?.("varga"); + const layer = chartTypeLayer(id); + if (layer) onNeedLayer?.(layer); } const birthline = view != null && view.status === "ok" diff --git a/frontend/src/components/chart-page/chart-type-picker.tsx b/frontend/src/components/chart-page/chart-type-picker.tsx new file mode 100644 index 00000000..9a8ed079 --- /dev/null +++ b/frontend/src/components/chart-page/chart-type-picker.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { Menu, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +import { + CHART_TYPE_ORDER, + CHART_TYPE_SHEET_COPY, + CHART_TYPE_STRIP_LABEL, + CHART_VIEW_COPY, + type ChartTypeId, +} from "@/lib/chart-view-labels"; +import { vargaMeaning } from "@/lib/chart-view-varga"; + +function sheetCopy(id: ChartTypeId): { label: string; use: string | null } { + const fixed = CHART_TYPE_SHEET_COPY[id]; + if (fixed) return fixed; + return { label: id, use: vargaMeaning(Number(id.slice(1))) }; +} + +/** + * The 「星盘类型」 sheet: every chart type in a three-column grid. A native + * `` so Esc, the backdrop and the focus trap come from the platform; + * focus returns to the 「≡」 button that opened it. + */ +function ChartTypeSheet({ current, onSelect, onClose }: { + current: string; + onSelect: (id: ChartTypeId) => void; + onClose: () => void; +}) { + const dialog = useRef(null); + + useEffect(() => { + const element = dialog.current; + const previous = document.activeElement; + element?.showModal(); + return () => { + element?.close(); + if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + + return ( + { event.preventDefault(); onClose(); }} + onClick={(event) => { if (event.target === event.currentTarget) onClose(); }} + > +
+
+

{CHART_VIEW_COPY.chartTypeSheetTitle}

+ +
+
+ {CHART_TYPE_ORDER.map((id) => { + const copy = sheetCopy(id); + return ( + + ); + })} +
+
+
+ ); +} + +/** + * Chart-type strip + 「≡」. The strip scrolls sideways and never wraps; the + * button stays pinned at the row's end and opens the full sheet. + */ +export function ChartTypePicker({ current, onSelect }: { + current: string; + onSelect: (id: ChartTypeId) => void; +}) { + const [sheetOpen, setSheetOpen] = useState(false); + const buttons = useRef(new Map()); + + function chooseFromSheet(id: ChartTypeId) { + setSheetOpen(false); + onSelect(id); + // A choice made in the sheet may sit far along the strip: bring it into view. + buttons.current.get(id)?.scrollIntoView({ block: "nearest", inline: "nearest" }); + } + + return ( +
+
+ {CHART_TYPE_ORDER.map((id) => ( + + ))} +
+ + {sheetOpen ? ( + setSheetOpen(false)} + onSelect={chooseFromSheet} + /> + ) : null} +
+ ); +} diff --git a/frontend/src/components/chart-page/chart-vedic-tab.tsx b/frontend/src/components/chart-page/chart-vedic-tab.tsx index fcd659c3..8deeb7e1 100644 --- a/frontend/src/components/chart-page/chart-vedic-tab.tsx +++ b/frontend/src/components/chart-page/chart-vedic-tab.tsx @@ -10,7 +10,8 @@ import type { ChartViewVargaAscendantRow, ChartViewVargaRow, } from "@/lib/chart-view-contract"; -import { CHART_VIEW_COPY, VARGA_CHIP_ORDER } from "@/lib/chart-view-labels"; +import { BIRTH_TIME_SENSITIVE_VARGAS, CHART_VIEW_COPY, type ChartTypeId } from "@/lib/chart-view-labels"; +import { ChartTypePicker } from "./chart-type-picker"; import { ASCENDANT_COLOR_CLASS, ASCENDANT_MARK, @@ -183,6 +184,11 @@ export function VargaTable({ varga }: { varga: ChartViewVarga }) { ); } +function PrecisionHint({ view, id }: { view: ChartViewOk; id: string }) { + if (!BIRTH_TIME_SENSITIVE_VARGAS.has(id) || view.profile.birthTimeStatus === "confirmed") return null; + return

{CHART_VIEW_COPY.precisionHint}

; +} + export function ChartVedicTab({ view, vargaId, @@ -191,10 +197,11 @@ export function ChartVedicTab({ failure, }: { view: ChartViewOk; + /** The selected chart type (`CHART_TYPE_ORDER`): a D-number, `MOON`, `BHAVA` or `TRANSIT`. */ vargaId: string; pending?: boolean; failure?: ChartViewFailureStatus; - onSelectVarga: (id: string) => void; + onSelectVarga: (id: ChartTypeId) => void; }) { const selected = view.vedic.vargas.find((item) => item.id === vargaId); const natal = view.vedic.vargas.find((item) => item.id === "D1") ?? view.vedic.vargas[0]; @@ -203,23 +210,7 @@ export function ChartVedicTab({ return (
-
- {VARGA_CHIP_ORDER.map((division) => { - const id = `D${division}`; - return ( - - ); - })} -
+ {waiting || !varga ? ( !pending && failure ?

{chartLayerFailureCopy(failure)}

: @@ -238,7 +229,10 @@ export function ChartVedicTab({ {varga.id === "D1" || !varga.rows ? ( ) : ( - +
+ + +
)}
)} diff --git a/frontend/src/lib/chart-view-labels.ts b/frontend/src/lib/chart-view-labels.ts index 9b1cb6eb..428d1f69 100644 --- a/frontend/src/lib/chart-view-labels.ts +++ b/frontend/src/lib/chart-view-labels.ts @@ -56,10 +56,51 @@ export const SIGN_ZH: Record = { Pisces: "双鱼", }; +/** Every divisional chart the page asks `/api/varga_full` for, D1 included. */ export const VARGA_CHIP_ORDER = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 20, 24, 27, 30, + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 20, 24, 27, 30, 40, 45, 60, ] as const; +/** + * The 23 chart types of the 星盘 tab, in the order the strip and the + * 「星盘类型」sheet show them (TASK-chart-types D2). `MOON` is the D1 counted + * from the Moon's sign, `BHAVA` the Sripati Bhava Chalit, `TRANSIT` today's + * sky placed in the natal houses. + */ +export const CHART_TYPE_ORDER = [ + "D1", "D9", "MOON", "BHAVA", "TRANSIT", + "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D10", "D11", "D12", + "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60", +] as const; + +export type ChartTypeId = (typeof CHART_TYPE_ORDER)[number]; + +/** Label on the strip: short, so the row stays one line at 375px. */ +export const CHART_TYPE_STRIP_LABEL: Partial> = { + D1: "本命", + MOON: "月亮", + BHAVA: "Bhava", + TRANSIT: "行运", +}; + +/** Big label and one-line use in the sheet, for the types that are not D-numbers. */ +export const CHART_TYPE_SHEET_COPY: Partial> = { + D1: { label: "本命", use: "D1" }, + MOON: { label: "月亮盘", use: "以月亮为第 1 宫" }, + BHAVA: { label: "Bhava", use: "不等宫" }, + TRANSIT: { label: "行运", use: "今天" }, +}; + +/** + * Divisional charts fine enough that a two-to-three minute birth-time error can + * move a body a whole sign (D40 0.75°, D45 0.67°, D60 0.5° per part). + */ +export const BIRTH_TIME_SENSITIVE_VARGAS: ReadonlySet = new Set(["D40", "D45", "D60"]); + +export function isChartTypeId(value: string): value is ChartTypeId { + return (CHART_TYPE_ORDER as readonly string[]).includes(value); +} + export const VIMSHOTTARI_ORDER = [ "Ketu", "Venus", @@ -147,6 +188,10 @@ export const CHART_VIEW_COPY = { waitingChart: "这一张盘还没拿到。", waitingLayer: "这一栏还没拿到。", waitingVarga: "这一分盘还没拿到。", + chartTypeSheetTitle: "星盘类型", + chartTypeMenu: "全部盘型", + chartTypeSheetClose: "关闭星盘类型", + precisionHint: "这张盘每格不到一度,出生时间差两三分钟就可能换一格。先做生时校正再看,会更稳。", unauthenticated: "请先登录后再看星盘。", incomplete: "还没有可用来排盘的出生资料。把日期、时间和地点填进星盘资料后,打开就能看见盘。", profileEntryTitle: "还没有星盘资料", diff --git a/frontend/tests/chart-type-picker.test.tsx b/frontend/tests/chart-type-picker.test.tsx new file mode 100644 index 00000000..d8f4eaba --- /dev/null +++ b/frontend/tests/chart-type-picker.test.tsx @@ -0,0 +1,129 @@ +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 { ChartTypePicker } from "../src/components/chart-page/chart-type-picker.tsx"; +import { ChartVedicTab } from "../src/components/chart-page/chart-vedic-tab.tsx"; +import { assembleChartView } from "../src/lib/chart-view-load.ts"; +import type { ChartViewOk } from "../src/lib/chart-view-contract.ts"; +import { CHART_TYPE_ORDER, CHART_VIEW_COPY, VARGA_CHIP_ORDER, type ChartTypeId } from "../src/lib/chart-view-labels.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")); +const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +const profile = { + name: "示例", + date: "1990-06-15", + time: "12:00", + placeLabel: "北京", + latitude: 39.9042, + longitude: 116.4074, + timezoneOffset: 8, + timezoneId: "Asia/Shanghai", + ayanamsa: "raman", + birthTimeStatus: "confirmed", +}; + +async function vargaView(birthTimeStatus = "confirmed"): Promise<{ view: ChartViewOk; divisions: unknown }> { + let divisions: unknown = null; + const result = await assembleChartView({ + userId: "user-1", + profile: { ...profile, birthTimeStatus }, + asOf: "2026-09-15", + layers: ["varga"], + postEngine: async (path, body) => { + if (path === "/api/chart") return { status: "ok", payload: golden.chart }; + if (path === "/api/varga_full") { divisions = body.divisions; return { status: "ok", payload: golden.varga_full }; } + return { status: "http_error", path, elapsedMs: 1, httpStatus: 500 }; + }, + }); + assert.equal(result.body.status, "ok"); + return { view: result.body as ChartViewOk, divisions }; +} + +test("the strip lists all 23 chart types in the decided order with a pinned 全部盘型 button", () => { + assert.deepEqual([...CHART_TYPE_ORDER], [ + "D1", "D9", "MOON", "BHAVA", "TRANSIT", + "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D10", "D11", "D12", + "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60", + ]); + const markup = renderToStaticMarkup(React.createElement(ChartTypePicker, { current: "D1", onSelect: () => {} })); + const strip = markup.match(/
([\s\S]*?)<\/div>/)?.[1] ?? ""; + const labels = [...strip.matchAll(/]*>([^<]*)<\/button>/g)].map((match) => match[1]); + assert.deepEqual(labels, ["本命", "D9", "月亮", "Bhava", "行运", "D2", "D3", "D4", "D5", "D6", "D7", "D8", "D10", "D11", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60"]); + assert.match(strip, /aria-selected="true"[^>]*>本命