feat(chart): 「那一刻的天空」封面 — engine, BFF, canvas painter, chart header entry
T1 scripts/birth_sky.py: real alt/az of the seven visible grahas (swe.calc_ut + swe.azalt, geometric altitude, azimuth converted from swe's south origin to north-east) and Yale BSC5 stars V<=4.0 (IAU 1976 precession); phase day / twilight_morning / twilight_evening / night. Thin POST /api/birth_sky registration in do_POST (no new handler method, no __new__). T2 GET /api/birth-sky?subject=: birth truth read server side through loadSubjectBirth + prepareChartViewProfile; zod contract; no billing, no writes, no birth data in the response. T3/T4 lib/birth-sky: sentence table, pure Canvas 2D drawBirthSky (night / paper palettes from DESIGN tokens, east on the left, serif headline with 1.5 s font wait), PNG export via navigator.share or Blob + a.download. Chart page prefetches and paints the cover once the chart is ok; the header button appears only when the PNG is ready. Dialog is a lazy chunk. T7 DESIGN §17, VOICE, CONTEXT glossary, CHANGELOG, real-device checklist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d670521f47
commit
d277cbd8ad
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./birth-sky.css";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import { saveBirthSkyImage } from "@/lib/birth-sky/export";
|
||||
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
|
||||
|
||||
export type BirthSkyDialogProps = Readonly<{
|
||||
cover: BirthSkyCover;
|
||||
onClose: () => void;
|
||||
/** First-chart reveal: fade in over the current step and offer 「继续」. */
|
||||
reveal?: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Native `<dialog>` like the report export drawer: full screen on phones,
|
||||
* centred on desktop. The picture is an `<img>` of the painted PNG, so a long
|
||||
* press saves it on iOS; 「保存图片」 shares or downloads the same file.
|
||||
*/
|
||||
export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialogProps) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [error, setError] = useState<string | null>(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 });
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function save() {
|
||||
if (saving.current) return;
|
||||
saving.current = true;
|
||||
setError(null);
|
||||
try {
|
||||
await saveBirthSkyImage(cover.blob);
|
||||
} catch {
|
||||
setError("没能保存。长按或右键上面的图,也能存下来。");
|
||||
} finally {
|
||||
saving.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
const description = `${birthSkyHeadline(cover.sky)}${birthSkyFactLine(cover.sky)}。`;
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className={reveal ? "birth-sky-dialog is-reveal" : "birth-sky-dialog"}
|
||||
aria-labelledby="birth-sky-title"
|
||||
onCancel={(event) => { event.preventDefault(); onClose(); }}
|
||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||
>
|
||||
<div className="birth-sky-panel">
|
||||
<header className="birth-sky-header">
|
||||
<h2 id="birth-sky-title">那一刻的天空</h2>
|
||||
<Button type="button" variant="ghost" onClick={onClose} aria-label="关闭">关闭</Button>
|
||||
</header>
|
||||
<div className="birth-sky-frame">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- a blob: URL of the painted PNG; long press must save the file itself */}
|
||||
<img className="birth-sky-image" src={cover.url} alt={description} width={1080} height={1440} />
|
||||
</div>
|
||||
<footer className="birth-sky-footer">
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
<Button type="button" variant="outline" onClick={() => void save()}>保存图片</Button>
|
||||
{reveal ? <Button type="button" onClick={onClose}>继续</Button> : null}
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
|
||||
const BirthSkyDialog = dynamic(
|
||||
() => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const BIRTH_SKY_ENTRY_LABEL = "那一刻的天空";
|
||||
|
||||
/**
|
||||
* The chart header's one extra action. Rendered only when the cover is already
|
||||
* painted; the dialog (and its CSS) is a lazy chunk opened on click.
|
||||
*/
|
||||
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
{BIRTH_SKY_ENTRY_LABEL}
|
||||
</Button>
|
||||
{open ? <BirthSkyDialog cover={cover} onClose={() => setOpen(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* 「那一刻的天空」 dialog (TASK-birth-sky-cover-20260928). Loaded with the lazy
|
||||
dialog chunk only, never on first paint. Tokens only; the picture itself is
|
||||
painted on canvas with the night palette. */
|
||||
.birth-sky-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink);
|
||||
overflow: hidden;
|
||||
}
|
||||
@supports (height: 1dvh) {
|
||||
.birth-sky-dialog { height: 100dvh; }
|
||||
}
|
||||
.birth-sky-dialog::backdrop { background: var(--color-scrim); }
|
||||
.birth-sky-panel { display: flex; flex-direction: column; height: 100%; }
|
||||
.birth-sky-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
padding-top: max(var(--space-2), env(safe-area-inset-top));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.birth-sky-header h2 { margin: 0; font-size: var(--type-title-md); font-weight: 500; }
|
||||
.birth-sky-frame {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
.birth-sky-image {
|
||||
display: block;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: var(--radius-md);
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
.birth-sky-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.birth-sky-footer p { flex-basis: 100%; margin: 0; text-align: center; color: var(--color-danger); font-size: var(--type-body-sm); }
|
||||
.birth-sky-dialog button { min-height: 44px; min-width: 44px; }
|
||||
@media (min-width: 860px) {
|
||||
.birth-sky-dialog {
|
||||
inset: 0;
|
||||
width: min(520px, 92vw);
|
||||
height: auto;
|
||||
max-height: 92vh;
|
||||
margin: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-elevated);
|
||||
}
|
||||
.birth-sky-panel { height: auto; max-height: calc(92vh - 2px); }
|
||||
.birth-sky-image { max-height: calc(92vh - 160px); }
|
||||
}
|
||||
/* First-chart reveal: one 600ms fade, no motion when the reader asked for none. */
|
||||
.birth-sky-dialog.is-reveal[open] { animation: birth-sky-reveal 600ms ease-out; }
|
||||
.birth-sky-dialog.is-reveal[open]::backdrop { animation: birth-sky-reveal 600ms ease-out; }
|
||||
@keyframes birth-sky-reveal { from { opacity: 0; } to { opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.birth-sky-dialog.is-reveal[open],
|
||||
.birth-sky-dialog.is-reveal[open]::backdrop { animation: none; }
|
||||
}
|
||||
@media print { .birth-sky-dialog { display: none !important; } }
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { BirthSkyEntry } from "@/components/birth-sky/birth-sky-entry";
|
||||
import { SecondaryPageShell } from "@/components/secondary-page-shell";
|
||||
import { useBirthSkyCover } from "@/hooks/use-birth-sky-cover";
|
||||
import { useChartPage } from "@/hooks/use-chart-page";
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import {
|
||||
CHART_VIEW_TABS,
|
||||
type ChartViewFailureStatus,
|
||||
@@ -22,12 +25,16 @@ import { ChartWesternTab } from "./chart-western-tab";
|
||||
|
||||
export function ChartPageRoute() {
|
||||
const { view, pendingLayers, layerFailures, requestLayer } = useChartPage();
|
||||
const ok = view?.status === "ok" ? view : null;
|
||||
// 「那一刻的天空」 is prefetched as soon as the chart is ok (TASK-birth-sky-cover T3).
|
||||
const birthSky = useBirthSkyCover(ok ? ok.subjectId ?? "self" : null, ok?.profileFingerprint ?? null);
|
||||
return (
|
||||
<ChartPageView
|
||||
view={view}
|
||||
pendingLayers={pendingLayers}
|
||||
layerFailures={layerFailures}
|
||||
onNeedLayer={requestLayer}
|
||||
birthSky={birthSky}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -66,12 +73,15 @@ export function ChartPageView({
|
||||
layerFailures,
|
||||
onNeedLayer,
|
||||
initialTab = "vedic",
|
||||
birthSky = null,
|
||||
}: {
|
||||
view: ChartViewResponse | null;
|
||||
pendingLayers?: ReadonlySet<ChartViewLayer>;
|
||||
layerFailures?: ReadonlyMap<ChartViewLayer, ChartViewFailureStatus>;
|
||||
onNeedLayer?: (layer: ChartViewLayer) => void;
|
||||
initialTab?: ChartViewTabId;
|
||||
/** Painted cover for the header button; null until ready, and after any failure. */
|
||||
birthSky?: BirthSkyCover | null;
|
||||
}) {
|
||||
const [tab, setTab] = useState<ChartViewTabId>(initialTab);
|
||||
const [vargaId, setVargaId] = useState<ChartTypeId>("D1");
|
||||
@@ -98,6 +108,7 @@ export function ChartPageView({
|
||||
<SecondaryPageShell
|
||||
title="星盘"
|
||||
note={birthline}
|
||||
actions={view?.status === "ok" && birthSky ? <BirthSkyEntry cover={birthSky} /> : undefined}
|
||||
>
|
||||
{view != null && view.status !== "ok" ? (
|
||||
<section className="chart-page-message">
|
||||
|
||||
Reference in New Issue
Block a user