feat(chart): browse the 子运 of any 分运 in 运期起止

The expanded 运期起止 tables drill three levels again, as before the
redesign: pick any 大运 to list its 分运, then pick any 分运 to list its
子运 with start and end. Picks start on the period containing the
observation day. Current rows are marked (aria-current), and a picked
row that is also current gets the pale accent wash. Pickable rows are
44px on phones. Tables stay nowrap and drag-scroll. The default 逐级放大
bars are unchanged.

Also fixes a test type error from the previous commit: tsc covers
tests, and the western-failure case built a Map<string, ...>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoyeZrYn6ryNcSe5Y2Y
This commit is contained in:
2026-11-02 12:21:45 +08:00
co-authored by Claude Opus 5.5
parent e393f8bbf5
commit ea387b6622
4 changed files with 127 additions and 47 deletions
@@ -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; }
@@ -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 (
<div className="chart-deep-detail">
<h5>{title}</h5>
<Wide>
<Table className={`chart-graha-table${onPick ? " is-pickable" : ""}`}>
<thead><tr><th scope="col">{head}</th><th scope="col">起</th><th scope="col">止</th></tr></thead>
<tbody>
{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 (
<tr
key={span.start}
className={tone || undefined}
aria-selected={onPick ? on : undefined}
aria-current={isNow ? "date" : undefined}
onClick={onPick ? () => onPick(span.start) : undefined}
>
<th scope="row">{span.label}</th>
<td className="chart-graha-num">{span.start}</td>
<td className="chart-graha-num">{span.end}</td>
</tr>
);
})}
</tbody>
</Table>
</Wide>
</div>
);
}
/**
* 运期起止: 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<string | null>(null);
const maha = periods.find((period) => period.start === picked) ?? now?.maha ?? periods[0];
const [pickedMaha, setPickedMaha] = useState<string | null>(null);
const [pickedAntar, setPickedAntar] = useState<string | null>(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 (
<div className="chart-deep-detail-grid">
<div className="chart-deep-detail">
<h5>大运</h5>
<Wide>
<Table className="chart-graha-table">
<thead><tr><th scope="col">大运</th><th scope="col">起</th><th scope="col">止</th></tr></thead>
<tbody>
{periods.map((period) => {
const on = maha?.start === period.start;
const isNow = now?.maha.start === period.start;
return (
<tr
key={period.start}
className={on ? "is-selected" : isNow ? "is-now" : undefined}
aria-selected={on}
onClick={() => setPicked(period.start)}
>
<th scope="row">{period.label}</th>
<td className="chart-graha-num">{period.start}</td>
<td className="chart-graha-num">{period.end}</td>
</tr>
);
})}
</tbody>
</Table>
</Wide>
</div>
<PeriodTable
title="大运"
head="大运"
spans={mahaSpans}
day={day}
picked={maha?.start ?? null}
onPick={(start) => { setPickedMaha(start); setPickedAntar(null); }}
/>
{maha ? (
<div className="chart-deep-detail">
<h5>{maha.label}大运的分运</h5>
<Wide>
<Table className="chart-graha-table">
<thead><tr><th scope="col">分运</th><th scope="col">起</th><th scope="col">止</th></tr></thead>
<tbody>
{maha.antardashas.map((item) => (
<tr key={item.start} className={item.start <= day && day < item.end ? "is-now" : undefined}>
<th scope="row">{item.label}</th>
<td className="chart-graha-num">{item.start}</td>
<td className="chart-graha-num">{item.end}</td>
</tr>
))}
</tbody>
</Table>
</Wide>
</div>
<PeriodTable
title={`${maha.label}大运的分运`}
head="分运"
spans={antars}
day={day}
picked={antar?.start ?? null}
onPick={setPickedAntar}
/>
) : null}
{antar ? <PeriodTable title={`${antar.label}分运的子运`} head="子运" spans={pratyantars} day={day} /> : null}
</div>
);
}
@@ -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();
}
});
+1 -1
View File
@@ -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<ChartViewLayer, ChartViewFailureStatus>([["western", "engine_busy"]]) }));
assert.ok(western.includes(CHART_VIEW_COPY.busy)); assert.doesNotMatch(western, /data-skeleton|这一栏还没拿到/);
const h = createClientLifecycleHarness();
try {