feat(chart): chart-type strip + 星盘类型 sheet, D40/D45/D60, precision hint (T2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-28 15:26:36 +08:00
co-authored by Claude Opus 5.5
parent f5149d10ca
commit 2071dc7c18
8 changed files with 780 additions and 28 deletions
@@ -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 <p className="chart-page-note">{copy}</p>;
}
@@ -63,7 +72,7 @@ export function ChartPageView({
initialTab?: ChartViewTabId;
}) {
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
const [vargaId, setVargaId] = useState("D1");
const [vargaId, setVargaId] = useState<ChartTypeId>("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"
@@ -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
* `<dialog>` 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<HTMLDialogElement>(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 (
<dialog
ref={dialog}
className="chart-page-type-sheet"
aria-labelledby="chart-page-type-sheet-title"
onCancel={(event) => { event.preventDefault(); onClose(); }}
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="chart-page-type-sheet-panel">
<header className="chart-page-type-sheet-header">
<h2 id="chart-page-type-sheet-title">{CHART_VIEW_COPY.chartTypeSheetTitle}</h2>
<button type="button" className="chart-page-type-sheet-close" aria-label={CHART_VIEW_COPY.chartTypeSheetClose} onClick={onClose}>
<X aria-hidden="true" />
</button>
</header>
<div className="chart-page-type-grid">
{CHART_TYPE_ORDER.map((id) => {
const copy = sheetCopy(id);
return (
<button
key={id}
type="button"
aria-pressed={id === current}
className={id === current ? "chart-page-type-cell is-current" : "chart-page-type-cell"}
onClick={() => onSelect(id)}
>
<strong>{copy.label}</strong>
{copy.use ? <small>{copy.use}</small> : null}
</button>
);
})}
</div>
</div>
</dialog>
);
}
/**
* 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<ChartTypeId, HTMLButtonElement>());
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 (
<div className="chart-page-type-bar">
<div className="chart-page-type-strip" role="tablist" aria-label="盘型">
{CHART_TYPE_ORDER.map((id) => (
<button
key={id}
type="button"
role="tab"
ref={(node) => {
if (node) buttons.current.set(id, node);
else buttons.current.delete(id);
}}
aria-selected={id === current}
className={id === current ? "chart-page-chip is-current" : "chart-page-chip"}
onClick={() => onSelect(id)}
>
{CHART_TYPE_STRIP_LABEL[id] ?? id}
</button>
))}
</div>
<button
type="button"
className="chart-page-type-menu"
aria-label={CHART_VIEW_COPY.chartTypeMenu}
aria-haspopup="dialog"
aria-expanded={sheetOpen}
onClick={() => setSheetOpen(true)}
>
<Menu aria-hidden="true" />
</button>
{sheetOpen ? (
<ChartTypeSheet
current={current}
onClose={() => setSheetOpen(false)}
onSelect={chooseFromSheet}
/>
) : null}
</div>
);
}
@@ -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 <p className="chart-page-note chart-page-precision-hint">{CHART_VIEW_COPY.precisionHint}</p>;
}
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 (
<div className="chart-page-vedic">
<div className="chart-page-varga-chips" role="tablist" aria-label="分盘">
{VARGA_CHIP_ORDER.map((division) => {
const id = `D${division}`;
return (
<button
key={id}
type="button"
role="tab"
aria-selected={id === vargaId}
className={id === vargaId ? "chart-page-chip is-current" : "chart-page-chip"}
onClick={() => onSelectVarga(id)}
>
{id}
</button>
);
})}
</div>
<ChartTypePicker current={vargaId} onSelect={onSelectVarga} />
{waiting || !varga ? (
!pending && failure ? <p className="chart-page-note">{chartLayerFailureCopy(failure)}</p>
: <ChartPageSkeleton copy={CHART_VIEW_COPY.waitingVarga} />
@@ -238,7 +229,10 @@ export function ChartVedicTab({
{varga.id === "D1" || !varga.rows ? (
<NatalTable view={view} caption={`${varga.id} · ${varga.meaning}`} />
) : (
<VargaTable varga={varga} />
<div className="chart-page-varga-table-block">
<PrecisionHint view={view} id={varga.id} />
<VargaTable varga={varga} />
</div>
)}
</div>
)}