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:
Jesse_Chen
2026-09-28 20:27:40 +08:00
co-authored by Claude Opus 5.5
parent d670521f47
commit d277cbd8ad
35 changed files with 2056 additions and 1 deletions
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { loadBirthSky } from "@/lib/birth-sky/service";
export const runtime = "nodejs";
export const maxDuration = 15;
/** 「那一刻的天空」: only `?subject=` is read; birth data never comes from the client. */
export async function GET(request: Request) {
const url = new URL(request.url);
const result = await loadBirthSky({ subjectId: url.searchParams.get("subject") });
return NextResponse.json(result.body, { status: result.httpStatus, headers: { "Cache-Control": "no-store" } });
}
@@ -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">
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/lib/birth-sky/cover";
/**
* 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.
*/
export function useBirthSkyCover(subjectId: string | null, fingerprint: string | null): BirthSkyCover | null {
const key = subjectId ? `${subjectId}:${fingerprint ?? ""}` : null;
const [cover, setCover] = useState<BirthSkyCover | null>(null);
useEffect(() => {
if (!key || !subjectId) return;
const controller = new AbortController();
let loaded: BirthSkyCover | null = null;
void loadBirthSkyCover(key, subjectId, controller.signal).then((next) => {
if (controller.signal.aborted) {
releaseBirthSkyCover(next);
return;
}
loaded = next;
if (next) setCover(next);
});
return () => {
controller.abort();
releaseBirthSkyCover(loaded);
};
}, [key, subjectId]);
return cover && cover.key === key ? cover : null;
}
+90
View File
@@ -0,0 +1,90 @@
import { z } from "zod";
/**
* 「那一刻的天空」 BFF contract (TASK-birth-sky-cover-20260928 T2).
*
* The response describes only the sky: altitudes, azimuths (degrees from
* north, eastward), star magnitudes. It never carries the birth date, time,
* coordinates or place — the server reads those from the owned profile row and
* keeps them.
*/
export const BIRTH_SKY_PHASES = ["day", "twilight_morning", "twilight_evening", "night"] as const;
export type BirthSkyPhase = (typeof BIRTH_SKY_PHASES)[number];
export const BIRTH_SKY_PLANET_IDS = ["sun", "moon", "mercury", "venus", "mars", "jupiter", "saturn"] as const;
export type BirthSkyPlanetId = (typeof BIRTH_SKY_PLANET_IDS)[number];
const altitude = z.number().min(-90).max(90);
const azimuth = z.number().min(0).max(360);
export const birthSkyPlanetSchema = z.strictObject({
id: z.enum(BIRTH_SKY_PLANET_IDS),
altitude,
azimuth,
});
export const birthSkyStarSchema = z.strictObject({
hr: z.number().int().positive(),
altitude,
azimuth,
magnitude: z.number().min(-2).max(4),
});
export const birthSkyLineSchema = z.strictObject({
id: z.string().min(1).max(40),
strokes: z.array(z.array(z.number().int().positive()).min(2).max(20)).min(1).max(8),
});
export const birthSkyOkSchema = z.strictObject({
status: z.literal("ok"),
phase: z.enum(BIRTH_SKY_PHASES),
sunAltitude: altitude,
planets: z.array(birthSkyPlanetSchema).length(BIRTH_SKY_PLANET_IDS.length),
stars: z.array(birthSkyStarSchema).max(2000),
lines: z.array(birthSkyLineSchema).max(8),
/** `reported`: the birth time is the one the user typed, not an adopted one. */
timeBasis: z.enum(["adopted", "reported"]),
});
export const BIRTH_SKY_FAILURES = [
"unauthenticated",
"birth_profile_incomplete",
"not_found",
"unavailable",
] as const;
export const birthSkyFailureSchema = z.strictObject({
status: z.enum(BIRTH_SKY_FAILURES),
});
export const birthSkyResponseSchema = z.union([birthSkyOkSchema, birthSkyFailureSchema]);
export type BirthSkyOk = z.infer<typeof birthSkyOkSchema>;
export type BirthSkyFailure = z.infer<typeof birthSkyFailureSchema>;
export type BirthSkyResponse = z.infer<typeof birthSkyResponseSchema>;
/** Raw `POST /api/birth_sky` payload from the Python engine (snake_case). */
const engineSchema = z.object({
phase: z.enum(BIRTH_SKY_PHASES),
sun_altitude: altitude,
planets: z.array(birthSkyPlanetSchema),
stars: z.array(birthSkyStarSchema),
lines: z.array(birthSkyLineSchema),
});
export function birthSkyFromEngine(payload: unknown, timeBasis: BirthSkyOk["timeBasis"]): BirthSkyOk | null {
const parsed = engineSchema.safeParse(payload);
if (!parsed.success) return null;
const candidate = {
status: "ok" as const,
phase: parsed.data.phase,
sunAltitude: parsed.data.sun_altitude,
planets: parsed.data.planets.map(({ id, altitude: alt, azimuth: az }) => ({ id, altitude: alt, azimuth: az })),
stars: parsed.data.stars.map(({ hr, altitude: alt, azimuth: az, magnitude }) => ({ hr, altitude: alt, azimuth: az, magnitude })),
lines: parsed.data.lines.map(({ id, strokes }) => ({ id, strokes })),
timeBasis,
};
const checked = birthSkyOkSchema.safeParse(candidate);
return checked.success ? checked.data : null;
}
+43
View File
@@ -0,0 +1,43 @@
import { birthSkyResponseSchema, type BirthSkyOk } from "./contract.ts";
/**
* A ready-to-show cover: the sky data plus the PNG already painted, so the
* button that opens it never has to wait (DESIGN §9).
*/
export type BirthSkyCover = Readonly<{
key: string;
sky: BirthSkyOk;
blob: Blob;
url: string;
}>;
export async function fetchBirthSky(subjectId: string, signal?: AbortSignal): Promise<BirthSkyOk | null> {
const query = new URLSearchParams({ subject: subjectId || "self" });
const response = await fetch(`/api/birth-sky?${query.toString()}`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
cache: "no-store",
signal,
});
if (!response.ok) return null;
const parsed = birthSkyResponseSchema.safeParse(await response.json().catch(() => null));
return parsed.success && parsed.data.status === "ok" ? parsed.data : null;
}
/** Fetch the sky and paint it. Resolves null on any failure; the caller shows nothing. */
export async function loadBirthSkyCover(key: string, subjectId: string, signal?: AbortSignal): Promise<BirthSkyCover | null> {
try {
const sky = await fetchBirthSky(subjectId, signal);
if (!sky || signal?.aborted) return null;
const { renderBirthSkyPng } = await import("./render.ts");
const blob = await renderBirthSkyPng(sky, "night");
if (signal?.aborted) return null;
return { key, sky, blob, url: URL.createObjectURL(blob) };
} catch {
return null;
}
}
export function releaseBirthSkyCover(cover: BirthSkyCover | null | undefined): void {
if (cover) URL.revokeObjectURL(cover.url);
}
+271
View File
@@ -0,0 +1,271 @@
import type { BirthSkyOk } from "./contract.ts";
import {
BIRTH_SKY_DISCLAIMER,
BIRTH_SKY_PLANET_NAMES,
BIRTH_SKY_TOP_LABEL,
birthSkyFactLine,
birthSkyFootnote,
birthSkyHeadline,
} from "./sentence.ts";
/**
* One pure Canvas 2D painter for the 3:4 「那一刻的天空」 cover. The preview
* `<img>`, the saved PNG and the print cover all come from this function, so
* web fonts never get lost in an SVG-to-image round trip.
*
* Projection: zenith at the centre, r = (90 − altitude) / 90 × R; north up,
* **east on the left** (we look up at the sky, not down at a map).
*/
export type BirthSkyPaletteName = "night" | "paper";
export type BirthSkyPalette = Readonly<{
background: string;
ink: string;
inkSecondary: string;
inkTertiary: string;
rule: string;
star: string;
sun: string;
}>;
/** Values are DESIGN.md tokens; no new brand colours. */
export const BIRTH_SKY_PALETTES: Readonly<Record<BirthSkyPaletteName, BirthSkyPalette>> = {
// Dark theme: page floor, ink, secondary / tertiary ink, hairline, action.
night: {
background: "#1a1a19",
ink: "#f2f0ea",
inkSecondary: "#b3afa4",
inkTertiary: "#9c988e",
rule: "#3c3b37",
star: "#f2f0ea",
sun: "#d78064",
},
// Report print sheet: --report-paper, ink, --report-rule, --report-accent.
paper: {
background: "#fbfaf7",
ink: "#1d1d1f",
inkSecondary: "#5f5f59",
inkTertiary: "#6a6963",
rule: "#d8d6cf",
star: "#1d1d1f",
sun: "#85432f",
},
};
export const BIRTH_SKY_EXPORT_WIDTH = 1080;
export const BIRTH_SKY_EXPORT_HEIGHT = 1440;
export const BIRTH_SKY_SERIF_FAMILY = "\"Jyotisha Serif SC\"";
export const BIRTH_SKY_SANS_STACK = "-apple-system, BlinkMacSystemFont, \"Segoe UI\", \"PingFang SC\", \"Microsoft YaHei\", sans-serif";
/** The design grid: 600 × 800 units, scaled to the canvas. */
const UNIT_WIDTH = 600;
const CENTER_X = 300;
const CENTER_Y = 400;
const RADIUS = 200;
export type BirthSkyDrawOptions = Readonly<{
palette: BirthSkyPaletteName | BirthSkyPalette;
width: number;
height: number;
/** Defaults to 「按你填写的时间」 when the birth time is not an adopted one. */
footnote?: string | null;
/** False when the heading serif did not load in time: draw in the sans stack. */
serif?: boolean;
}>;
/** Subset of CanvasRenderingContext2D this painter touches (tests pass a recorder). */
export type BirthSkyCanvas = Pick<
CanvasRenderingContext2D,
| "save" | "restore" | "scale" | "fillRect" | "beginPath" | "closePath" | "arc" | "moveTo" | "lineTo"
| "fill" | "stroke" | "clip" | "setLineDash" | "fillText" | "measureText"
| "fillStyle" | "strokeStyle" | "lineWidth" | "globalAlpha" | "font" | "textAlign" | "textBaseline"
>;
export type SkyPoint = Readonly<{ x: number; y: number }>;
/** Zenith-centred projection in design units: north up, east left. */
export function skyPoint(altitude: number, azimuth: number): SkyPoint {
const r = ((90 - altitude) / 90) * RADIUS;
const a = (azimuth * Math.PI) / 180;
return { x: CENTER_X - r * Math.sin(a), y: CENTER_Y - r * Math.cos(a) };
}
export const BIRTH_SKY_CENTER: SkyPoint = { x: CENTER_X, y: CENTER_Y };
function resolvePalette(palette: BirthSkyDrawOptions["palette"]): BirthSkyPalette {
return typeof palette === "string" ? BIRTH_SKY_PALETTES[palette] : palette;
}
function font(weight: number, size: number, serif: boolean): string {
return `${weight} ${size}px ${serif ? `${BIRTH_SKY_SERIF_FAMILY}, ${BIRTH_SKY_SANS_STACK}` : BIRTH_SKY_SANS_STACK}`;
}
function starRadius(magnitude: number): number {
return Math.max(0.7, 2.4 - magnitude * 0.55);
}
type Box = { x0: number; y0: number; x1: number; y1: number };
function overlaps(a: Box, b: Box): boolean {
return a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1;
}
export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: BirthSkyDrawOptions): void {
const palette = resolvePalette(options.palette);
const serif = options.serif ?? true;
const footnote = options.footnote === undefined ? birthSkyFootnote(sky) : options.footnote;
const unit = options.width / UNIT_WIDTH;
ctx.save();
ctx.fillStyle = palette.background;
ctx.fillRect(0, 0, options.width, options.height);
ctx.scale(unit, unit);
ctx.textBaseline = "alphabetic";
// Sky disc, clipped so nothing below the horizon leaks.
ctx.save();
ctx.beginPath();
ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2);
ctx.clip();
ctx.strokeStyle = palette.star;
ctx.lineWidth = 0.5;
ctx.globalAlpha = 0.22;
ctx.setLineDash([1, 4]);
for (const ring of [30, 60]) {
ctx.beginPath();
ctx.arc(CENTER_X, CENTER_Y, ((90 - ring) / 90) * RADIUS, 0, Math.PI * 2);
ctx.stroke();
}
ctx.setLineDash([]);
const visible = new Map<number, SkyPoint>();
ctx.fillStyle = palette.star;
ctx.globalAlpha = 0.85;
for (const star of sky.stars) {
if (star.altitude <= 0) continue;
const point = skyPoint(star.altitude, star.azimuth);
visible.set(star.hr, point);
ctx.beginPath();
ctx.arc(point.x, point.y, starRadius(star.magnitude), 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = palette.star;
ctx.lineWidth = 0.6;
ctx.globalAlpha = 0.4;
for (const line of sky.lines) {
for (const stroke of line.strokes) {
for (let index = 1; index < stroke.length; index += 1) {
const from = visible.get(stroke[index - 1]);
const to = visible.get(stroke[index]);
if (!from || !to) continue;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
}
}
const planets = sky.planets.filter((planet) => planet.altitude > 0);
for (const planet of planets) {
const point = skyPoint(planet.altitude, planet.azimuth);
if (planet.id === "sun") {
ctx.fillStyle = palette.sun;
ctx.globalAlpha = 0.18;
ctx.beginPath();
ctx.arc(point.x, point.y, 16, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = palette.sun;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.arc(point.x, point.y, 6, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(point.x, point.y, 1.6, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillStyle = palette.star;
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(point.x, point.y, 3.2, 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
// Planet names sit outside the clip so a body near the horizon keeps its label.
ctx.font = font(400, 11, false);
const labels: Box[] = [];
for (const planet of planets) {
const point = skyPoint(planet.altitude, planet.azimuth);
const name = BIRTH_SKY_PLANET_NAMES[planet.id];
const width = 24;
const candidates = planet.id === "sun"
? [{ dx: 20, dy: 4, align: "left" as const }, { dx: -20, dy: 4, align: "right" as const }, { dx: 0, dy: -22, align: "center" as const }, { dx: 0, dy: 30, align: "center" as const }]
: [{ dx: 8, dy: -7, align: "left" as const }, { dx: 8, dy: 15, align: "left" as const }, { dx: -8, dy: -7, align: "right" as const }, { dx: -8, dy: 15, align: "right" as const }];
const box = (choice: (typeof candidates)[number]): Box => {
const x0 = choice.align === "left" ? point.x + choice.dx : choice.align === "right" ? point.x + choice.dx - width : point.x - width / 2;
return { x0, y0: point.y + choice.dy - 11, x1: x0 + width, y1: point.y + choice.dy + 2 };
};
const choice = candidates.find((item) => !labels.some((taken) => overlaps(box(item), taken))) ?? candidates[0];
labels.push(box(choice));
ctx.fillStyle = planet.id === "sun" ? palette.sun : palette.ink;
ctx.globalAlpha = 0.9;
ctx.textAlign = choice.align;
ctx.fillText(name, point.x + choice.dx, point.y + choice.dy);
}
ctx.strokeStyle = palette.star;
ctx.lineWidth = 0.9;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = palette.inkSecondary;
ctx.globalAlpha = 0.9;
ctx.textAlign = "center";
ctx.font = font(400, 12, false);
for (const [text, dx, dy] of [["北", 0, -1], ["南", 0, 1], ["东", -1, 0], ["西", 1, 0]] as const) {
ctx.fillText(text, CENTER_X + dx * (RADIUS + 18), CENTER_Y + dy * (RADIUS + 18) + 4);
}
// Text frame (sample C): label, rule, headline … rule, fact, footnote, disclaimer.
ctx.textAlign = "left";
ctx.fillStyle = palette.inkSecondary;
ctx.globalAlpha = 1;
ctx.font = font(600, 12, serif);
// Tracked by hand (canvas letterSpacing is not in every Safari we support).
let cursor = 48;
for (const glyph of BIRTH_SKY_TOP_LABEL) {
ctx.fillText(glyph, cursor, 64);
cursor += ctx.measureText(glyph).width + 2;
}
ctx.fillStyle = palette.rule;
ctx.fillRect(48, 80, 504, 0.8);
ctx.fillRect(48, 650, 504, 0.8);
ctx.fillStyle = palette.ink;
ctx.font = font(600, 30, serif);
ctx.fillText(birthSkyHeadline(sky), 48, 146, 504);
ctx.font = font(600, 15, false);
ctx.fillText(birthSkyFactLine(sky), 48, 686, 504);
if (footnote) {
ctx.fillStyle = palette.inkSecondary;
ctx.font = font(400, 12.5, false);
ctx.fillText(footnote, 48, 712, 504);
}
ctx.fillStyle = palette.inkTertiary;
ctx.font = font(400, 11, false);
ctx.textAlign = "center";
ctx.fillText(BIRTH_SKY_DISCLAIMER, 300, 764);
ctx.restore();
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Saving the 「那一刻的天空」 PNG (TASK-birth-sky-cover-20260928 T4).
*
* The file name carries no name and no date. Where the browser can share files
* (iOS Safari: 「存储图像」 in the system sheet) we hand the file to
* `navigator.share`; everywhere else it is the same Blob + `a.download` pattern
* as `downloadMarkdownReport`. A dismissed share sheet is not an error.
*/
export const BIRTH_SKY_FILENAME = "jyotisha-sky.png";
export type BirthSkySaveOutcome = "shared" | "downloaded" | "cancelled";
type ShareNavigator = {
canShare?: (data: ShareData) => boolean;
share?: (data: ShareData) => Promise<void>;
};
export type BirthSkySaveEnvironment = {
readonly navigator?: ShareNavigator | null;
readonly makeFile?: (blob: Blob, name: string) => File;
readonly download?: (blob: Blob, name: string) => void;
};
export function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("birth_sky_png_unavailable"));
}, "image/png");
});
}
export function downloadBlob(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function isShareCancel(error: unknown): boolean {
return error instanceof Error && error.name === "AbortError";
}
export async function saveBirthSkyImage(blob: Blob, env: BirthSkySaveEnvironment = {}): Promise<BirthSkySaveOutcome> {
const nav = env.navigator === undefined
? (typeof navigator === "undefined" ? null : navigator as ShareNavigator)
: env.navigator;
const makeFile = env.makeFile ?? ((data: Blob, name: string) => new File([data], name, { type: "image/png" }));
const download = env.download ?? downloadBlob;
const file = makeFile(blob, BIRTH_SKY_FILENAME);
let canShare = false;
try {
canShare = Boolean(nav?.share && nav.canShare?.({ files: [file] }));
} catch {
canShare = false;
}
if (canShare && nav?.share) {
try {
await nav.share({ files: [file] });
return "shared";
} catch (error) {
if (isShareCancel(error)) return "cancelled";
// A share sheet that fails for any other reason still owes the reader the file.
}
}
download(blob, BIRTH_SKY_FILENAME);
return "downloaded";
}
+60
View File
@@ -0,0 +1,60 @@
import type { BirthSkyOk } from "./contract.ts";
import {
BIRTH_SKY_EXPORT_HEIGHT,
BIRTH_SKY_EXPORT_WIDTH,
BIRTH_SKY_SERIF_FAMILY,
drawBirthSky,
type BirthSkyCanvas,
type BirthSkyPaletteName,
} from "./draw.ts";
import { BIRTH_SKY_TOP_LABEL, birthSkyHeadline } from "./sentence.ts";
import { canvasToPngBlob } from "./export.ts";
/** Decision 7: wait for the heading serif at most this long, then draw in sans. */
export const BIRTH_SKY_FONT_WAIT_MS = 1_500;
type FontLoader = { load: (font: string, text?: string) => Promise<unknown> };
/**
* Resolves true once the slices that cover the headline and the top label are
* loaded, false on timeout or failure. Never rejects, never shows a waiting state.
*/
export async function waitForHeadingSerif(
text: string,
fonts: FontLoader | null | undefined = typeof document === "undefined" ? null : document.fonts,
timeoutMs = BIRTH_SKY_FONT_WAIT_MS,
): Promise<boolean> {
if (!fonts) return false;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
});
const load = fonts.load(`600 30px ${BIRTH_SKY_SERIF_FAMILY}`, text)
.then((faces) => Array.isArray(faces) ? faces.length > 0 : true)
.catch(() => false);
try {
return await Promise.race([load, timeout]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
/** Paint the cover on an off-screen canvas and return the PNG. Browser only. */
export async function renderBirthSkyPng(
sky: BirthSkyOk,
palette: BirthSkyPaletteName = "night",
): Promise<Blob> {
const serif = await waitForHeadingSerif(`${birthSkyHeadline(sky)}${BIRTH_SKY_TOP_LABEL}`);
const canvas = document.createElement("canvas");
canvas.width = BIRTH_SKY_EXPORT_WIDTH;
canvas.height = BIRTH_SKY_EXPORT_HEIGHT;
const context = canvas.getContext("2d");
if (!context) throw new Error("birth_sky_canvas_unavailable");
drawBirthSky(context as BirthSkyCanvas, sky, {
palette,
width: BIRTH_SKY_EXPORT_WIDTH,
height: BIRTH_SKY_EXPORT_HEIGHT,
serif,
});
return canvasToPngBlob(canvas);
}
+60
View File
@@ -0,0 +1,60 @@
import type { BirthSkyOk, BirthSkyPhase, BirthSkyPlanetId } from "./contract.ts";
/**
* The only sentences the cover may print (TASK-birth-sky-cover-20260928 T3).
* They describe the sky and nothing else: no dasha, no ascendant, no character.
* Deterministic — the same sky always gets the same words.
*/
export const BIRTH_SKY_HEADLINES: Readonly<Record<BirthSkyPhase, string>> = {
day: "星星都在,只是还看不见。",
twilight_morning: "天快亮了,星星正一颗颗退场。",
twilight_evening: "天刚暗下来,星星正一颗颗出来。",
night: "那一刻,头顶就是这片天。",
};
export const BIRTH_SKY_TOP_LABEL = "JYOTISHA · 那 一 刻";
export const BIRTH_SKY_DISCLAIMER = "仅供自我探索,不替你做决定。";
export const BIRTH_SKY_REPORTED_FOOTNOTE = "按你填写的时间";
export const BIRTH_SKY_PLANET_NAMES: Readonly<Record<BirthSkyPlanetId, string>> = {
sun: "太阳",
moon: "月亮",
mercury: "水星",
venus: "金星",
mars: "火星",
jupiter: "木星",
saturn: "土星",
};
const OCTANTS = ["北", "东北", "东", "东南", "南", "西南", "西", "西北"] as const;
/** Eight-point compass name for an azimuth measured from north, eastward. */
export function octantName(azimuth: number): string {
const normalized = ((azimuth % 360) + 360) % 360;
return OCTANTS[Math.round(normalized / 45) % 8];
}
export function birthSkyHeadline(sky: Pick<BirthSkyOk, "phase">): string {
return BIRTH_SKY_HEADLINES[sky.phase];
}
/**
* 「太阳在东,月亮在脚下,火星正悬在东南」: the Sun only by day, then the Moon
* (above or below the horizon), then the highest other planet above it.
*/
export function birthSkyFactLine(sky: Pick<BirthSkyOk, "phase" | "planets">): string {
const parts: string[] = [];
const sun = sky.planets.find((planet) => planet.id === "sun");
if (sky.phase === "day" && sun) parts.push(`太阳在${octantName(sun.azimuth)}`);
const moon = sky.planets.find((planet) => planet.id === "moon");
if (moon) parts.push(moon.altitude > 0 ? `月亮在${octantName(moon.azimuth)}` : "月亮在脚下");
const highest = sky.planets
.filter((planet) => planet.id !== "sun" && planet.id !== "moon" && planet.altitude > 0)
.sort((left, right) => right.altitude - left.altitude)[0];
if (highest) parts.push(`${BIRTH_SKY_PLANET_NAMES[highest.id]}正悬在${octantName(highest.azimuth)}`);
return parts.join(",");
}
export function birthSkyFootnote(sky: Pick<BirthSkyOk, "timeBasis">): string | null {
return sky.timeBasis === "reported" ? BIRTH_SKY_REPORTED_FOOTNOTE : null;
}
@@ -0,0 +1,77 @@
import { prepareChartViewProfile } from "../chart-view-profile-read.ts";
import { ConsultationSubjectError, subjectIdFromQuery, type ResolvedSubjectBirth } from "../subject-birth.ts";
import { birthSkyFromEngine, type BirthSkyResponse } from "./contract.ts";
/**
* GET /api/birth-sky core (TASK-birth-sky-cover-20260928 T2).
*
* Birth truth is read on the server, from the owned profile row, exactly as the
* chart page reads it (`loadSubjectBirth` → `prepareChartViewProfile`). No
* client date or coordinate is accepted, so the endpoint cannot be used to draw
* an arbitrary sky. Never bills, never writes.
*/
export type BirthSkyEngineBody = Readonly<{
date: string;
time: string;
timezone_offset: number;
latitude: number;
longitude: number;
}>;
export type BirthSkyDeps = Readonly<{
userId: string | null;
subjectId: unknown;
loadSubject: (userId: string, subjectId: string) => Promise<ResolvedSubjectBirth>;
/** Returns the engine payload, or null on any engine failure. */
postEngine: (body: BirthSkyEngineBody) => Promise<unknown>;
prepareProfile?: typeof prepareChartViewProfile;
rateLimited?: boolean;
}>;
export type BirthSkyResult = Readonly<{ httpStatus: number; body: BirthSkyResponse }>;
const ADOPTED_STATUSES = new Set(["accepted", "confirmed"]);
export async function assembleBirthSky(deps: BirthSkyDeps): Promise<BirthSkyResult> {
if (!deps.userId) return { httpStatus: 401, body: { status: "unauthenticated" } };
if (deps.rateLimited) return { httpStatus: 429, body: { status: "unavailable" } };
const subjectId = subjectIdFromQuery(deps.subjectId);
let resolved: ResolvedSubjectBirth;
try {
resolved = await deps.loadSubject(deps.userId, subjectId);
} catch (error) {
if (error instanceof ConsultationSubjectError) {
if (error.code === "subject_incomplete") return { httpStatus: 200, body: { status: "birth_profile_incomplete" } };
if (error.code === "subject_not_found" || error.code === "subject_role_mismatch") {
return { httpStatus: 404, body: { status: "not_found" } };
}
}
return { httpStatus: 503, body: { status: "unavailable" } };
}
const prepared = await (deps.prepareProfile ?? prepareChartViewProfile)({ row: resolved.row });
if (!prepared.ok) {
return prepared.failure === "profile_incomplete"
? { httpStatus: 200, body: { status: "birth_profile_incomplete" } }
: { httpStatus: 503, body: { status: "unavailable" } };
}
const { profile } = prepared;
let payload: unknown = null;
try {
payload = await deps.postEngine({
date: profile.date,
time: profile.time,
timezone_offset: profile.timezoneOffset,
latitude: profile.latitude,
longitude: profile.longitude,
});
} catch {
payload = null;
}
const timeBasis = ADOPTED_STATUSES.has(profile.birthTimeStatus ?? "") ? "adopted" : "reported";
const sky = payload == null ? null : birthSkyFromEngine(payload, timeBasis);
if (!sky) return { httpStatus: 503, body: { status: "unavailable" } };
return { httpStatus: 200, body: sky };
}
+39
View File
@@ -0,0 +1,39 @@
import "server-only";
import { consumeRequestRateLimit } from "../request-rate-limit.ts";
import { createServerSupabaseClient } from "../supabase/server.ts";
import { loadSubjectBirth } from "../subject-birth.ts";
import { assembleBirthSky, type BirthSkyEngineBody, type BirthSkyResult } from "./service-core.ts";
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
export const BIRTH_SKY_ENGINE_TIMEOUT_MS = 10_000;
async function postBirthSky(body: BirthSkyEngineBody): Promise<unknown> {
const response = await fetch(`${jyotishApiBase}/api/birth_sky`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: AbortSignal.timeout(BIRTH_SKY_ENGINE_TIMEOUT_MS),
});
if (!response.ok) {
console.warn("birth_sky_engine_failed", { httpStatus: response.status });
return null;
}
return await response.json() as unknown;
}
export async function loadBirthSky(input: { subjectId: unknown }): Promise<BirthSkyResult> {
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
const rateLimited = user
? !consumeRequestRateLimit({ key: `birthSky:${user.id}`, limit: 20, windowMs: 60_000 }).ok
: false;
return assembleBirthSky({
userId: user?.id ?? null,
subjectId: input.subjectId,
rateLimited,
loadSubject: (userId, subjectId) => loadSubjectBirth(supabase, userId, subjectId),
postEngine: postBirthSky,
});
}