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,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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user