fix(chart,reports): chart card hugs the drawing, symbol help as a bubble, sky button shows at once, report card spacing and copy (BUG-1100..1103)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
This commit is contained in:
Jesse_Chen
2026-09-29 20:19:32 +08:00
co-authored by Claude Opus 5.5
parent 0120765f42
commit c59e362521
14 changed files with 357 additions and 49 deletions
+39 -6
View File
@@ -3884,7 +3884,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
still reads calm; the button inside is the page's one filled action. */
.report-center-create {
width: min(900px, 100%);
margin: 0 auto var(--space-8);
/* Top margin: the card used to sit flush under the 46px header (2026-09-29). */
margin: var(--space-6) auto var(--space-8);
display: flex;
flex-direction: column;
align-items: center;
@@ -5211,6 +5212,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (prefers-reduced-motion: reduce) {
.chart-page-skeleton { animation: none; opacity: 0.5; }
}
/* 「那一刻的天空」: the chart header's one extra action, tinted with the accent and
a sparkle so it reads as an invitation rather than a thin outline (2026-09-29).
A 12% mix keeps it light in both themes (not the --color-action-soft block, BUG-1078). */
.birth-sky-entry[data-slot="button"] {
background: color-mix(in srgb, var(--color-action) 12%, transparent);
color: var(--color-action);
font-size: var(--type-body-sm);
font-weight: 600;
}
.birth-sky-entry[data-slot="button"]:hover { background: color-mix(in srgb, var(--color-action) 20%, transparent); }
.birth-sky-entry svg { width: 16px; height: 16px; }
.chart-page-vedic-stage {
position: relative;
background: var(--color-canvas);
@@ -5227,12 +5239,31 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.chart-page-vedic-plot { margin-inline: auto; }
/* Reserve the result's legend band without changing the shared empty drawing. */
.chart-page-skeleton.chart-page-vedic-plot { aspect-ratio: 400 / 452; }
/* Keep the explanation outside the drawing so it cannot cover nearby symbols. */
.chart-page-note.chart-page-symbol-note {
min-height: 2.8em;
margin: var(--space-2) 0 0;
/* Symbol help is a bubble at the mark (2026-09-29, replaced the note line under
the chart): above the mark, below it near the top edge, clamped inside the
stage by ChartSymbolHelp. Hover on desktop, tap on touch (closes on a tap
elsewhere or after 3s). It never takes pointer events, so it cannot block
the mark next to it. */
.chart-page-symbol-help { position: relative; }
.chart-symbol-bubble {
position: absolute;
z-index: 5;
max-width: 220px;
transform: translate(-50%, calc(-100% - 8px));
padding: 6px 10px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
box-shadow: var(--shadow-elevated);
color: var(--color-ink);
font-size: var(--type-caption);
line-height: 1.4;
text-align: center;
pointer-events: none;
white-space: normal;
width: max-content;
}
.chart-symbol-bubble.is-below { transform: translate(-50%, 8px); }
.chart-page-symbol-hit { fill: transparent; stroke: none; }
.chart-page-symbol-target { cursor: help; }
.chart-page-symbol-target:focus-visible {
@@ -5616,7 +5647,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
align-items: start;
}
/* Limit the drawing, not its padded stage or the parameter/table column. */
.chart-page-vedic-plot { max-width: 440px; }
/* The card hugs the drawing (2026-09-29): capping only the plot left a wide
card with empty sides around a 440px chart. 440 + the card's padding. */
.chart-page-vedic-stage { width: 100%; max-width: calc(440px + 2 * var(--space-3) + 2px); }
.chart-page-western-wheel { max-width: 480px; }
/* Chart and parameters sit side by side; the planet table is a full-width
row under both, because its columns need the whole measure. */
@@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { Sparkles } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
@@ -14,18 +15,23 @@ const BirthSkyReplay = dynamic(
export const BIRTH_SKY_ENTRY_LABEL = "那一刻的天空";
/**
* The chart header's one extra action. Rendered only when the cover is already
* painted; the replay (convergence, controls and CSS) is a lazy chunk opened on
* click, and every open plays it again (TASK-mobile-chart-and-confirmed-edit-20260929 M4).
* The chart header's one extra action. Shown as soon as the chart is ready,
* while the cover PNG may still be painting (2026-09-29: it used to pop in about
* a second late). A click before the cover is ready is remembered and the
* replay opens the moment it arrives — no spinner in between; the replay's own
* convergence is the wait. The replay is a lazy chunk and every open plays it
* again (TASK-mobile-chart-and-confirmed-edit-20260929 M4).
*/
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
const [open, setOpen] = useState(false);
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover | null }) {
const [wanted, setWanted] = useState(false);
const open = wanted && cover !== null;
return (
<>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
<Button type="button" variant="secondary" size="sm" className="birth-sky-entry" onClick={() => setWanted(true)}>
<Sparkles aria-hidden="true" />
{BIRTH_SKY_ENTRY_LABEL}
</Button>
{open ? <BirthSkyReplay cover={cover} onClose={() => setOpen(false)} /> : null}
{open ? <BirthSkyReplay cover={cover} onClose={() => setWanted(false)} /> : null}
</>
);
}
@@ -34,7 +34,8 @@ export function ChartPageRoute() {
pendingLayers={pendingLayers}
layerFailures={layerFailures}
onNeedLayer={requestLayer}
birthSky={birthSky}
birthSky={birthSky.cover}
birthSkyPending={birthSky.pending}
/>
);
}
@@ -74,6 +75,7 @@ export function ChartPageView({
onNeedLayer,
initialTab = "vedic",
birthSky = null,
birthSkyPending = false,
}: {
view: ChartViewResponse | null;
pendingLayers?: ReadonlySet<ChartViewLayer>;
@@ -82,6 +84,8 @@ export function ChartPageView({
initialTab?: ChartViewTabId;
/** Painted cover for the header button; null until ready, and after any failure. */
birthSky?: BirthSkyCover | null;
/** The cover is still painting: the button already shows and opens when it lands. */
birthSkyPending?: boolean;
}) {
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
@@ -108,7 +112,7 @@ export function ChartPageView({
<SecondaryPageShell
title="星盘"
note={birthline}
actions={view?.status === "ok" && birthSky ? <BirthSkyEntry cover={birthSky} /> : undefined}
actions={view?.status === "ok" && (birthSky || birthSkyPending) ? <BirthSkyEntry cover={birthSky ?? null} /> : undefined}
>
{view != null && view.status !== "ok" ? (
<section className="chart-page-message">
@@ -1,13 +1,21 @@
"use client";
import { useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";
type Explain = (text: string | null) => void;
/** `anchor` is the mark the bubble points at; without one the last anchor is kept. */
export type Explain = (text: string | null, anchor?: Element | null) => void;
type Props = {
resetKey: string;
children: ReactNode | ((explain: Explain) => ReactNode);
};
/** How long a tapped bubble stays up on touch screens, which have no pointer-leave to close it. */
export const TOUCH_BUBBLE_MS = 3000;
/** Room kept between the bubble and the stage edge; the bubble never runs off it. */
const EDGE = 8;
/** Half the bubble's max width (see `.chart-symbol-bubble`), for clamping the centre. */
const HALF_WIDTH = 110;
/** The drawing only supplies facts. All transient help stays on the chart page. */
export function ChartSymbolHelp({ resetKey, children }: Props) {
return <SymbolHelpSession key={resetKey}>{children}</SymbolHelpSession>;
@@ -22,26 +30,94 @@ function helpTarget(target: EventTarget | null): Element | null {
return null;
}
type Bubble = { text: string; x: number; y: number; below: boolean };
/**
* Where the bubble goes, in the stage's own coordinates: centred over the mark,
* above it unless that would leave the stage, and pulled in from the sides so
* the text never runs off the chart (2026-09-29, product: a bubble at the mark,
* not a line of text under the chart).
*/
export function placeBubble(
mark: Pick<DOMRect, "left" | "top" | "width" | "height">,
stage: Pick<DOMRect, "left" | "top" | "width">,
): Omit<Bubble, "text"> {
const centre = mark.left - stage.left + mark.width / 2;
const x = Math.min(Math.max(centre, HALF_WIDTH + EDGE), Math.max(stage.width - HALF_WIDTH - EDGE, HALF_WIDTH + EDGE));
const top = mark.top - stage.top;
const below = top < 48;
return { x, y: below ? top + mark.height : top, below };
}
function SymbolHelpSession({ children }: Pick<Props, "children">) {
const [help, setHelp] = useState<string | null>(null);
const stage = useRef<HTMLDivElement>(null);
const [bubble, setBubble] = useState<Bubble | null>(null);
const touchTimer = useRef<number | null>(null);
function show(text: string | null, anchor: Element | null | undefined) {
if (!text) {
setBubble(null);
return;
}
// Found from the mark rather than a ref: `explain` is handed to the drawing during render.
const box = anchor?.closest(".chart-page-symbol-help")?.getBoundingClientRect();
const mark = anchor?.getBoundingClientRect();
if (!box || !mark) {
// No geometry for this mark: keep the last position, or the stage corner. Never drop the text.
setBubble((current) => ({ x: HALF_WIDTH + EDGE, y: 0, below: true, ...current, text }));
return;
}
setBubble({ text, ...placeBubble(mark, box) });
}
const explainTarget = (target: EventTarget | null) => {
setHelp(helpTarget(target)?.getAttribute("data-chart-help") ?? null);
const element = helpTarget(target);
show(element?.getAttribute("data-chart-help") ?? null, element);
};
const explain: Explain = (text, anchor) => show(text, anchor);
function clearTouchTimer() {
if (touchTimer.current !== null) window.clearTimeout(touchTimer.current);
touchTimer.current = null;
}
// A tap anywhere outside the chart closes a tapped bubble.
useEffect(() => {
if (!bubble) return;
const close = (event: PointerEvent) => {
if (stage.current && event.target instanceof Node && !stage.current.contains(event.target)) setBubble(null);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, [bubble]);
useEffect(() => clearTouchTimer, []);
return (
<div
ref={stage}
className="chart-page-symbol-help"
onPointerOver={(event) => explainTarget(event.target)}
onPointerMove={(event) => explainTarget(event.target)}
onPointerOver={(event) => {
if (event.pointerType !== "touch") explainTarget(event.target);
}}
onPointerOut={(event) => {
if (helpTarget(event.target) !== helpTarget(event.relatedTarget)) setHelp(null);
// Touch fires pointer-out right after the tap; the bubble must survive it.
if (event.pointerType === "touch") return;
if (helpTarget(event.target) !== helpTarget(event.relatedTarget)) setBubble(null);
}}
onPointerLeave={(event) => {
if (event.pointerType !== "touch") setBubble(null);
}}
onPointerLeave={() => setHelp(null)}
onFocus={(event) => explainTarget(event.target)}
onBlur={() => setHelp(null)}
onBlur={() => setBubble(null)}
onClick={(event) => explainTarget(event.target)}
onPointerUp={(event) => {
if (event.pointerType !== "touch") return;
explainTarget(event.target);
clearTouchTimer();
touchTimer.current = window.setTimeout(() => setBubble(null), TOUCH_BUBBLE_MS);
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setHelp(null);
setBubble(null);
event.stopPropagation();
} else if (event.key === "Enter" || event.key === " ") {
const target = helpTarget(event.target);
@@ -52,10 +128,18 @@ function SymbolHelpSession({ children }: Pick<Props, "children">) {
}
}}
>
{typeof children === "function" ? children(setHelp) : children}
<p className="chart-page-note chart-page-symbol-note" role="status" aria-live="polite" aria-atomic="true">
{help ?? "停在或轻点盘内标记可查看说明。"}
</p>
{typeof children === "function" ? children(explain) : children}
{bubble ? (
<div
className={`chart-symbol-bubble${bubble.below ? " is-below" : ""}`}
role="tooltip"
style={{ left: bubble.x, top: bubble.y }}
>
{bubble.text}
</div>
) : null}
{/* Screen readers still hear the same sentence the bubble shows. */}
<p className="sr-only" role="status" aria-live="polite" aria-atomic="true">{bubble?.text ?? ""}</p>
</div>
);
}
@@ -85,7 +85,7 @@ export function WesternWheelSvg({
skeleton?: boolean;
selectedLabel?: string | null;
onSelectPlanet?: (id: string) => void;
onExplain?: (text: string | null) => void;
onExplain?: (text: string | null, anchor?: Element | null) => void;
}) {
const western = skeleton ? EMPTY_WESTERN_WHEEL : suppliedWestern;
const asc = western.ascendantLongitude;
@@ -134,16 +134,22 @@ export function WesternWheelSvg({
function explainNearest(event: PointerEvent<SVGElement>, fallbackId: string) {
if (!onExplain) return;
event.stopPropagation();
onExplain(planetDescription(nearestId(event, fallbackId)));
const id = nearestId(event, fallbackId);
onExplain(planetDescription(id), planetAnchor(event.currentTarget, id));
}
function selectNearest(event: MouseEvent<SVGElement>, fallbackId: string) {
const id = nearestId(event, fallbackId);
onSelectPlanet?.(id);
if (onExplain) {
event.stopPropagation();
onExplain(planetDescription(id));
onExplain(planetDescription(id), planetAnchor(event.currentTarget, id));
}
}
/** The bubble points at the planet that was resolved, not merely the one under the pointer. */
function planetAnchor(from: EventTarget, id: string): Element | null {
if (!(from instanceof Element)) return null;
return from.closest("svg")?.querySelector(`[data-planet-id="${CSS.escape(id)}"]`) ?? from;
}
function helpProps(text: string) {
return onExplain ? {
className: "chart-page-symbol-target", role: "button", tabIndex: 0,
@@ -236,6 +242,7 @@ export function WesternWheelSvg({
key={planet.id}
className={selected ? "chart-page-western-planet is-selected" : "chart-page-western-planet"}
data-tier={placement.tier}
data-planet-id={planet.id}
role="button"
tabIndex={0}
aria-pressed={selected}
@@ -246,14 +253,14 @@ export function WesternWheelSvg({
onFocus={(event) => {
if (!onExplain) return;
event.stopPropagation();
onExplain(planetDescription(planet.id));
onExplain(planetDescription(planet.id), event.currentTarget);
}}
onClick={(event) => selectNearest(event, planet.id)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onSelectPlanet?.(planet.id);
onExplain?.(planetDescription(planet.id));
onExplain?.(planetDescription(planet.id), event.currentTarget);
}}
>
<line
@@ -157,8 +157,10 @@ export function PersonalReportCenter() {
entry: the header no longer carries a second button (2026-09-29). */}
<section className="report-center-create" aria-labelledby="report-center-create-title">
<FileText aria-hidden="true" className="size-7" />
<h2 id="report-center-create-title">生成个人报告</h2>
<p>创建后即可离开,完成时下方列表会自动更新。</p>
{/* The title says what you get, the button says what it does (2026-09-29):
「生成个人报告」 over a 「生成报告」 button said the same thing twice. */}
<h2 id="report-center-create-title">完整本命报告</h2>
<p>星盘、力量、大运、年运、瑜伽共 6 章,中英两版;生成后可离开,完成时下方自动出现。</p>
<GeneratePersonalReportButton onCreated={() => void load()} />
</section>
<section className="report-center-section">
+11 -5
View File
@@ -8,13 +8,17 @@ import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/l
* Chart page prefetch for 「那一刻的天空」 (TASK-birth-sky-cover-20260928 T3).
*
* Starts as soon as the chart is `ok`, keyed by subject + profile fingerprint so
* switching person (or editing birth data) fetches again. Returns the cover
* only once the PNG is painted; until then, and on any failure, null — the
* header shows no button, no placeholder, no error.
* switching person (or editing birth data) fetches again. `cover` is set once
* the PNG is painted. `pending` is true while it is still being painted, so the
* header can show the button at once instead of popping it in a second later
* (2026-09-29); a failure clears both and the button goes away without an error.
*/
export function useBirthSkyCover(subjectId: string | null, fingerprint: string | null): BirthSkyCover | null {
export type BirthSkyCoverState = Readonly<{ cover: BirthSkyCover | null; pending: boolean }>;
export function useBirthSkyCover(subjectId: string | null, fingerprint: string | null): BirthSkyCoverState {
const key = subjectId ? `${subjectId}:${fingerprint ?? ""}` : null;
const [cover, setCover] = useState<BirthSkyCover | null>(null);
const [settledKey, setSettledKey] = useState<string | null>(null);
useEffect(() => {
if (!key || !subjectId) return;
@@ -27,6 +31,7 @@ export function useBirthSkyCover(subjectId: string | null, fingerprint: string |
}
loaded = next;
if (next) setCover(next);
setSettledKey(key);
});
return () => {
controller.abort();
@@ -34,5 +39,6 @@ export function useBirthSkyCover(subjectId: string | null, fingerprint: string |
};
}, [key, subjectId]);
return cover && cover.key === key ? cover : null;
const current = cover && cover.key === key ? cover : null;
return { cover: current, pending: key !== null && current === null && settledKey !== key };
}