diff --git a/frontend/src/components/chart-board/chart-board.css b/frontend/src/components/chart-board/chart-board.css
index 76329248..1f06e27b 100644
--- a/frontend/src/components/chart-board/chart-board.css
+++ b/frontend/src/components/chart-board/chart-board.css
@@ -494,7 +494,8 @@
.chart-deep-toggle svg { transition: transform .15s ease-out; }
.chart-deep-toggle.is-open svg { transform: rotate(180deg); }
.chart-deep-more-panel { display: grid; gap: var(--space-4); min-width: 0; }
-.chart-deep-detail-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--space-6); min-width: 0; }
+.chart-deep-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-6); min-width: 0; }
+.chart-deep .chart-graha-table.is-pickable tbody tr.is-selected.is-now > * { background: var(--deep-now); color: var(--deep-now-ink); }
.chart-deep-detail { display: grid; gap: var(--space-2); min-width: 0; align-content: start; }
.chart-deep-detail h5 { margin: 0; font-size: 13px; font-weight: 600; color: var(--color-ink-secondary); }
.chart-deep-actions { display: flex; justify-content: flex-end; }
@@ -594,6 +595,8 @@
border: 1px solid var(--color-border); border-radius: 12px; color: var(--color-ink-strong); font-size: 14px;
}
.chart-deep-detail-grid { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); }
+ .chart-deep .chart-graha-table.is-pickable tbody th,
+ .chart-deep .chart-graha-table.is-pickable tbody td { height: 44px; padding-top: 0; padding-bottom: 0; }
.chart-deep-shadbala-row[data-slot="button"] { grid-template-columns: 22px minmax(0, 1fr) 48px; gap: 10px; }
.chart-deep-glyph { font-size: 17px; }
.chart-deep-meter { height: 10px; border-radius: 5px; }
diff --git a/frontend/src/components/chart-board/chart-deep-reading.tsx b/frontend/src/components/chart-board/chart-deep-reading.tsx
index c8cbd0e8..330fa762 100644
--- a/frontend/src/components/chart-board/chart-deep-reading.tsx
+++ b/frontend/src/components/chart-board/chart-deep-reading.tsx
@@ -302,58 +302,85 @@ function LifeRuler({ view, day, dayWord }: { view: ChartViewOk; day: string; day
);
}
+/** One level of 运期起止: name, start, end; the current row is marked, a pickable row opens the next level. */
+function PeriodTable({ title, head, spans, day, picked, onPick }: {
+ title: string;
+ head: string;
+ spans: readonly DashaSpan[];
+ day: string;
+ picked?: string | null;
+ onPick?: (start: string) => void;
+}) {
+ return (
+
+
{title}
+
+
+ | {head} | 起 | 止 |
+
+ {spans.map((span) => {
+ const on = onPick ? picked === span.start : false;
+ const isNow = span.start <= day && day < span.end;
+ const tone = [on ? "is-selected" : "", isNow ? "is-now" : ""].filter(Boolean).join(" ");
+ return (
+ onPick(span.start) : undefined}
+ >
+ | {span.label} |
+ {span.start} |
+ {span.end} |
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+/**
+ * 运期起止: pick any 大运 → its 分运 → pick any 分运 → its 子运. Both picks
+ * start on the period containing the observation day.
+ */
function PeriodTables({ view, day }: { view: ChartViewOk; day: string }) {
const periods = view.dasha.vimshottari.periods;
const now = vimshottariAt(view, day);
- const [picked, setPicked] = useState(null);
- const maha = periods.find((period) => period.start === picked) ?? now?.maha ?? periods[0];
+ const [pickedMaha, setPickedMaha] = useState(null);
+ const [pickedAntar, setPickedAntar] = useState(null);
+ const maha = periods.find((period) => period.start === pickedMaha) ?? now?.maha ?? periods[0];
+ const antars: DashaSpan[] = maha ? maha.antardashas.map((item) => ({ lord: item.lord, label: item.label, start: item.start, end: item.end })) : [];
+ const antar = antars.find((item) => item.start === pickedAntar)
+ ?? antars.find((item) => item.start <= day && day < item.end)
+ ?? antars[0]
+ ?? null;
+ const pratyantars = antar ? vimshottariSplit(antar.lord, antar.start, antar.end) : [];
+ const mahaSpans: DashaSpan[] = periods.map((period) => ({ lord: period.lord, label: period.label, start: period.start, end: period.end }));
return (
-
-
大运
-
-
- | 大运 | 起 | 止 |
-
- {periods.map((period) => {
- const on = maha?.start === period.start;
- const isNow = now?.maha.start === period.start;
- return (
- setPicked(period.start)}
- >
- | {period.label} |
- {period.start} |
- {period.end} |
-
- );
- })}
-
-
-
-
+
{ setPickedMaha(start); setPickedAntar(null); }}
+ />
{maha ? (
-
-
{maha.label}大运的分运
-
-
- | 分运 | 起 | 止 |
-
- {maha.antardashas.map((item) => (
-
- | {item.label} |
- {item.start} |
- {item.end} |
-
- ))}
-
-
-
-
+
) : null}
+ {antar ? : null}
);
}
diff --git a/frontend/tests/chart-deep-reading-20261102.test.tsx b/frontend/tests/chart-deep-reading-20261102.test.tsx
index 23137a50..8432daa3 100644
--- a/frontend/tests/chart-deep-reading-20261102.test.tsx
+++ b/frontend/tests/chart-deep-reading-20261102.test.tsx
@@ -9,6 +9,7 @@ import type { ChartBoardState } from "../src/hooks/use-chart-board.ts";
import { natalBoard } from "../src/lib/chart-board-view.ts";
import type { BoardSelection } from "../src/lib/chart-board-model.ts";
import { assembleChartView } from "../src/lib/chart-view-load.ts";
+import { vimshottariSplit } from "../src/lib/dasha-span.ts";
import type { ChartViewOk } from "../src/lib/chart-view-contract.ts";
import { CHART_VIEW_LAYERS, type ChartViewLayer } from "../src/lib/chart-view-engine.ts";
import { cssDeclarations } from "./css-contract-test-support.ts";
@@ -249,3 +250,52 @@ test("深读 CSS: page flow, theme tokens only, 44px phone targets", () => {
assert.match(block, /@media \(max-width: 640px\) \{[\s\S]*\.chart-deep-ruler-age\.is-minor \{ display: none; \}/, "ticks every 20 years on a phone");
assert.match(block, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*animation: none/);
});
+
+test("运期起止 drills from any 大运 to any 分运 to its 子运, current rows marked", async () => {
+ const view = await okView();
+ const day = view.dasha.asOf;
+ const periods = view.dasha.vimshottari.periods;
+ const currentMaha = periods.find((period) => period.start <= day && day < period.end)!;
+ const currentAntar = currentMaha.antardashas.find((item) => item.start <= day && day < item.end)!;
+ const otherMaha = periods.find((period) => period.start !== currentMaha.start && period.antardashas.length > 1)!;
+ const otherAntar = otherMaha.antardashas[1]!;
+ const h = createClientLifecycleHarness();
+ const rowsOf = (title: string) => {
+ const heading = h.elements().find((node) => node.tagName === "H5" && node.text === title);
+ assert.ok(heading, `missing table ${title}`);
+ return heading!.parentNode as unknown as { text: string };
+ };
+ const row = (startIso: string, label: string) => h.elements().find((node) => node.tagName === "TR" && node.text.startsWith(`${label}${startIso}`));
+ try {
+ await h.render(React.createElement(ChartDeepReading, {
+ view,
+ board: fakeBoard(view),
+ observe: day,
+ onObserve: () => {},
+ transit: { transit: null, pending: false, failed: false },
+ }));
+ const toggle = h.elements().find((node) => node.tagName === "BUTTON" && node.text === "看运期起止和第二条运法")!;
+ await h.event(toggle);
+ // Default: the current 大运 and 分运, and the current 分运's 子运 with start and end.
+ const sub = vimshottariSplit(currentAntar.lord, currentAntar.start, currentAntar.end);
+ let box = rowsOf(`${currentAntar.label}分运的子运`);
+ for (const span of sub) assert.ok(box.text.includes(`${span.label}${span.start}${span.end}`), span.label);
+ const nowRows = h.elements().filter((node) => node.tagName === "TR" && node.getAttribute("aria-current") === "date");
+ assert.equal(nowRows.length, 3, "one current row per level");
+ // Any 大运 → any 分运 → its 子运.
+ await h.event(row(otherMaha.start, otherMaha.label)!);
+ rowsOf(`${otherMaha.label}大运的分运`);
+ await h.event(row(otherAntar.start, otherAntar.label)!);
+ box = rowsOf(`${otherAntar.label}分运的子运`);
+ for (const span of vimshottariSplit(otherAntar.lord, otherAntar.start, otherAntar.end)) {
+ assert.ok(box.text.includes(`${span.label}${span.start}${span.end}`), span.label);
+ }
+ const picked = h.elements().filter((node) => node.tagName === "TR" && node.getAttribute("aria-selected") === "true");
+ assert.equal(picked.length, 2, "the picked 大运 and 分运");
+ // The 逐级放大 bars still show the current periods.
+ assert.match(h.container.text, new RegExp(`${currentMaha.label}大运`));
+ assert.match(h.container.text, /今天 · 已走 \d+%/);
+ } finally {
+ await h.close();
+ }
+});
diff --git a/frontend/tests/chart-page-view.test.tsx b/frontend/tests/chart-page-view.test.tsx
index f29f06ff..60817c58 100644
--- a/frontend/tests/chart-page-view.test.tsx
+++ b/frontend/tests/chart-page-view.test.tsx
@@ -535,7 +535,7 @@ test("varga pending shows the empty shared chart and failure replaces it", async
// 原因: 2026-11-02 深读改版,第二条运法收在展开按钮后面
test("western and chara failures are terminal copy rather than waiting", async () => {
const view = await okView();
- const western = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western", layerFailures: new Map([["western", "engine_busy" as const]]) }));
+ const western = withSidebarProvider(React.createElement(ChartPageView, { view, initialTab: "western", layerFailures: new Map([["western", "engine_busy"]]) }));
assert.ok(western.includes(CHART_VIEW_COPY.busy)); assert.doesNotMatch(western, /data-skeleton|这一栏还没拿到/);
const h = createClientLifecycleHarness();
try {