feat(onboarding): first-chart reveal becomes a star convergence, no buttons (F4)
TASK-birth-sky-followup-20260928 F4: - pure timeline / seeded scatter / interpolation in lib/birth-sky/converge.ts; drawBirthSky takes an optional frame (positions interpolated, alphas multiplied) so the settled frame is the exported cover call for call - lazy full-screen <dialog> overlay driven by requestAnimationFrame at viewport x DPR; tap / click / Esc end it; reduced motion only fades - data, serif and overlay chunk must all arrive inside the 3 s budget, otherwise skipped silently; page.tsx wiring unchanged - one sonner hint after it ends; dialog reveal variant (继续, is-reveal) removed - DESIGN §17, VOICE, CHANGELOG, checklist, PROGRESS with verification 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:
co-authored by
Claude Opus 5.5
parent
a9a917d50c
commit
731c3b364b
@@ -0,0 +1,25 @@
|
||||
/* First-chart convergence (TASK-birth-sky-followup-20260928 F4). Loaded with
|
||||
the lazy overlay chunk only. Full screen, no buttons; opacity is driven
|
||||
frame by frame from requestAnimationFrame, never from CSS keyframes. The
|
||||
background is the night palette's floor, set inline from the painter. */
|
||||
.birth-sky-converge {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
}
|
||||
.birth-sky-converge:not([open]) { display: none; }
|
||||
.birth-sky-converge::backdrop { background: transparent; }
|
||||
.birth-sky-converge canvas { display: block; }
|
||||
@supports (height: 1dvh) { .birth-sky-converge { height: 100dvh; } }
|
||||
@media print { .birth-sky-converge { display: none !important; } }
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import "./birth-sky-converge.css";
|
||||
|
||||
import { birthSkyRevealAt, birthSkyScatter } from "@/lib/birth-sky/converge";
|
||||
import { BIRTH_SKY_PALETTES, drawBirthSky, type BirthSkyCanvas } from "@/lib/birth-sky/draw";
|
||||
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
|
||||
import { birthSkyFactLine, birthSkyHeadline } from "@/lib/birth-sky/sentence";
|
||||
|
||||
/** Largest 3:4 box that fits the viewport, in CSS pixels. */
|
||||
export function birthSkyConvergeSize(viewportWidth: number, viewportHeight: number) {
|
||||
const width = Math.max(1, Math.min(viewportWidth, (viewportHeight * 3) / 4));
|
||||
return { width, height: (width * 4) / 3 };
|
||||
}
|
||||
|
||||
/**
|
||||
* First-chart convergence (TASK-birth-sky-followup-20260928 F4). A full-screen
|
||||
* layer with no buttons: the stars converge, the words appear, the layer fades
|
||||
* and the step underneath (unchanged) is simply there. A tap, a click or Esc
|
||||
* ends it at once. Every frame is `drawBirthSky` with interpolated positions,
|
||||
* so the settled frame is the exported cover.
|
||||
*/
|
||||
export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky; onDone: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const layer = dialog.current;
|
||||
const surface = canvas.current;
|
||||
const context = surface?.getContext("2d");
|
||||
if (!layer || !surface || !context) {
|
||||
onDone();
|
||||
return;
|
||||
}
|
||||
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
||||
const scatter = birthSkyScatter(reveal.sky);
|
||||
let frameHandle = 0;
|
||||
let started: number | null = null;
|
||||
let finished = false;
|
||||
|
||||
const fit = () => {
|
||||
const size = birthSkyConvergeSize(window.innerWidth, window.innerHeight);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
surface.style.width = `${size.width}px`;
|
||||
surface.style.height = `${size.height}px`;
|
||||
surface.width = Math.round(size.width * ratio);
|
||||
surface.height = Math.round(size.height * ratio);
|
||||
};
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
cancelAnimationFrame(frameHandle);
|
||||
onDone();
|
||||
};
|
||||
const tick = (now: number) => {
|
||||
if (finished) return;
|
||||
started ??= now;
|
||||
const moment = birthSkyRevealAt(now - started, reduced);
|
||||
layer.style.opacity = String(moment.layer);
|
||||
drawBirthSky(context as BirthSkyCanvas, reveal.sky, {
|
||||
palette: "night",
|
||||
width: surface.width,
|
||||
height: surface.height,
|
||||
serif: reveal.serif,
|
||||
}, { ...moment.frame, scatter });
|
||||
if (moment.done) finish();
|
||||
else frameHandle = requestAnimationFrame(tick);
|
||||
};
|
||||
const cancel = (event: Event) => {
|
||||
event.preventDefault();
|
||||
finish();
|
||||
};
|
||||
|
||||
fit();
|
||||
layer.style.opacity = "0";
|
||||
layer.showModal();
|
||||
layer.addEventListener("cancel", cancel);
|
||||
layer.addEventListener("pointerdown", finish);
|
||||
window.addEventListener("resize", fit);
|
||||
frameHandle = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
finished = true;
|
||||
cancelAnimationFrame(frameHandle);
|
||||
layer.removeEventListener("cancel", cancel);
|
||||
layer.removeEventListener("pointerdown", finish);
|
||||
window.removeEventListener("resize", fit);
|
||||
layer.close();
|
||||
};
|
||||
}, [reveal, onDone]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="birth-sky-converge"
|
||||
aria-label="那一刻的天空"
|
||||
style={{ background: BIRTH_SKY_PALETTES.night.background }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvas}
|
||||
role="img"
|
||||
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
|
||||
/>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,6 @@ 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;
|
||||
}>;
|
||||
|
||||
/**
|
||||
@@ -21,7 +19,7 @@ export type BirthSkyDialogProps = Readonly<{
|
||||
* 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) {
|
||||
export function BirthSkyDialog({ cover, onClose }: BirthSkyDialogProps) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const saving = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -53,7 +51,7 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className={reveal ? "birth-sky-dialog is-reveal" : "birth-sky-dialog"}
|
||||
className="birth-sky-dialog"
|
||||
aria-labelledby="birth-sky-title"
|
||||
onCancel={(event) => { event.preventDefault(); onClose(); }}
|
||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||
@@ -70,7 +68,6 @@ export function BirthSkyDialog({ cover, onClose, reveal = false }: BirthSkyDialo
|
||||
<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>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import type { BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
|
||||
|
||||
const BirthSkyDialog = dynamic(
|
||||
() => import("./birth-sky-dialog").then((module) => module.BirthSkyDialog),
|
||||
const BirthSkyConverge = dynamic(
|
||||
() => import("./birth-sky-converge").then((module) => module.BirthSkyConverge),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/** First-chart reveal: the chart page's dialog, faded in once, with 「继续」. */
|
||||
export function BirthSkyReveal({ cover, onClose }: { cover: BirthSkyCover | null; onClose: () => void }) {
|
||||
return cover ? <BirthSkyDialog cover={cover} onClose={onClose} reveal /> : null;
|
||||
/** First-chart reveal: the stars converge once over the next step, then get out of the way. */
|
||||
export function BirthSkyReveal({ cover, onClose }: { cover: BirthSkyRevealSky | null; onClose: () => void }) {
|
||||
return cover ? <BirthSkyConverge reveal={cover} onDone={onClose} /> : null;
|
||||
}
|
||||
|
||||
@@ -74,12 +74,4 @@
|
||||
.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; } }
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { loadBirthSkyCover, releaseBirthSkyCover, type BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import { startBirthSkyReveal } from "@/lib/birth-sky/reveal";
|
||||
import { loadBirthSkyRevealSky, startBirthSkyReveal, type BirthSkyRevealSky } from "@/lib/birth-sky/reveal";
|
||||
|
||||
/** Shown once on the home page after the convergence ends (VOICE.md). */
|
||||
export const BIRTH_SKY_REVEAL_HINT = "这片天空在星盘页可以保存。";
|
||||
|
||||
/**
|
||||
* The one-time 「那一刻的天空」 reveal after the reader's own first chart
|
||||
* (TASK-birth-sky-cover-20260928 T6). Owns its state so Home only wires it:
|
||||
* `begin` goes to the onboarding hook, `cover` / `close` to the dialog.
|
||||
* Saving one's own birth place happens once per account, so no "seen" flag.
|
||||
* The one-time 「那一刻的天空」 convergence after the reader's own first chart
|
||||
* (TASK-birth-sky-cover-20260928 T6 → TASK-birth-sky-followup-20260928 F4).
|
||||
* Owns its state so Home only wires it: `begin` goes to the onboarding hook,
|
||||
* `cover` / `close` to the overlay. `cover` is the sky plus the serif verdict,
|
||||
* ready before the first frame. Saving one's own birth place happens once per
|
||||
* account, so no "seen" flag.
|
||||
*/
|
||||
export function useBirthSkyReveal() {
|
||||
const [cover, setCover] = useState<BirthSkyCover | null>(null);
|
||||
const [cover, setCover] = useState<BirthSkyRevealSky | null>(null);
|
||||
const cancel = useRef<(() => void) | null>(null);
|
||||
const playing = useRef(false);
|
||||
|
||||
const begin = useCallback(() => {
|
||||
cancel.current?.();
|
||||
cancel.current = startBirthSkyReveal({
|
||||
load: (signal) => loadBirthSkyCover("self:reveal", "self", signal),
|
||||
onReady: (next) => setCover(next),
|
||||
onDiscard: releaseBirthSkyCover,
|
||||
cancel.current = startBirthSkyReveal<BirthSkyRevealSky>({
|
||||
// The overlay chunk loads inside the same budget, so the first frame never waits for code.
|
||||
load: async (signal) => {
|
||||
const [sky] = await Promise.all([
|
||||
loadBirthSkyRevealSky(signal),
|
||||
import("@/components/birth-sky/birth-sky-converge"),
|
||||
]);
|
||||
return sky;
|
||||
},
|
||||
onReady: (next) => {
|
||||
playing.current = true;
|
||||
setCover(next);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setCover((current) => {
|
||||
releaseBirthSkyCover(current);
|
||||
return null;
|
||||
});
|
||||
if (!playing.current) return;
|
||||
playing.current = false;
|
||||
setCover(null);
|
||||
toast(BIRTH_SKY_REVEAL_HINT);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => { cancel.current?.(); }, []);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { BirthSkyOk } from "./contract.ts";
|
||||
import {
|
||||
BIRTH_SKY_CENTER,
|
||||
BIRTH_SKY_RADIUS,
|
||||
birthSkyBodyKey,
|
||||
interpolateSkyPoint,
|
||||
skyPoint,
|
||||
type BirthSkyFrame,
|
||||
type SkyPoint,
|
||||
} from "./draw.ts";
|
||||
|
||||
/**
|
||||
* First-chart convergence (TASK-birth-sky-followup-20260928 F4): the visible
|
||||
* stars and planets travel from seeded scattered positions to their true
|
||||
* projection, then the words appear, then the whole layer fades out. Pure
|
||||
* functions only; the overlay feeds them `requestAnimationFrame` time.
|
||||
*/
|
||||
|
||||
export const BIRTH_SKY_CONVERGE_MS = 1_500;
|
||||
export const BIRTH_SKY_TEXT_MS = 1_000;
|
||||
export const BIRTH_SKY_FADE_MS = 500;
|
||||
export const BIRTH_SKY_REVEAL_TOTAL_MS = BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS + BIRTH_SKY_FADE_MS;
|
||||
/** Words fade in over the first part of their second, then hold. */
|
||||
const TEXT_FADE_IN_MS = 400;
|
||||
/** The layer itself comes in quickly so the page does not flash to dark. */
|
||||
const LAYER_FADE_IN_MS = 200;
|
||||
/** Reduced motion: no travel, one fade in, a hold, one fade out. */
|
||||
const REDUCED_FADE_IN_MS = 400;
|
||||
/** Scattered stars stay inside the horizon so the clip never hides them. */
|
||||
const SCATTER_RADIUS = BIRTH_SKY_RADIUS * 0.95;
|
||||
|
||||
export type BirthSkyVisibleBody = Readonly<{ key: string; point: SkyPoint }>;
|
||||
|
||||
/** Bodies drawn on the cover (above the horizon), keyed as the painter keys them. */
|
||||
export function birthSkyVisibleBodies(sky: BirthSkyOk): readonly BirthSkyVisibleBody[] {
|
||||
return [
|
||||
...sky.stars
|
||||
.filter((star) => star.altitude > 0)
|
||||
.map((star) => ({ key: birthSkyBodyKey("star", star.hr), point: skyPoint(star.altitude, star.azimuth) })),
|
||||
...sky.planets
|
||||
.filter((planet) => planet.altitude > 0)
|
||||
.map((planet) => ({ key: birthSkyBodyKey("planet", planet.id), point: skyPoint(planet.altitude, planet.azimuth) })),
|
||||
];
|
||||
}
|
||||
|
||||
/** Seed from the chart itself, so the same chart always scatters the same way. */
|
||||
export function birthSkySeed(sky: BirthSkyOk): number {
|
||||
let hash = 0x811c9dc5;
|
||||
const feed = (value: string) => {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
};
|
||||
for (const body of birthSkyVisibleBodies(sky)) feed(`${body.key}@${body.point.x.toFixed(3)},${body.point.y.toFixed(3)};`);
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
/** mulberry32: small, fast, deterministic. */
|
||||
function random(seed: number): () => number {
|
||||
let state = seed >>> 0;
|
||||
return () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Uniform scattered start points inside the horizon, one per visible body. */
|
||||
export function birthSkyScatter(sky: BirthSkyOk, seed = birthSkySeed(sky)): ReadonlyMap<string, SkyPoint> {
|
||||
const next = random(seed);
|
||||
const scatter = new Map<string, SkyPoint>();
|
||||
for (const body of birthSkyVisibleBodies(sky)) {
|
||||
const radius = Math.sqrt(next()) * SCATTER_RADIUS;
|
||||
const angle = next() * Math.PI * 2;
|
||||
scatter.set(body.key, {
|
||||
x: BIRTH_SKY_CENTER.x + radius * Math.cos(angle),
|
||||
y: BIRTH_SKY_CENTER.y + radius * Math.sin(angle),
|
||||
});
|
||||
}
|
||||
return scatter;
|
||||
}
|
||||
|
||||
/** Positions at progress t (0 = scattered, 1 = true). */
|
||||
export function birthSkyPositionsAt(
|
||||
sky: BirthSkyOk,
|
||||
scatter: ReadonlyMap<string, SkyPoint>,
|
||||
t: number,
|
||||
): ReadonlyMap<string, SkyPoint> {
|
||||
const positions = new Map<string, SkyPoint>();
|
||||
for (const body of birthSkyVisibleBodies(sky)) {
|
||||
positions.set(body.key, interpolateSkyPoint(scatter.get(body.key) ?? body.point, body.point, t));
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
export function easeOutCubic(t: number): number {
|
||||
if (t <= 0) return 0;
|
||||
if (t >= 1) return 1;
|
||||
return 1 - (1 - t) ** 3;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value <= 0 ? 0 : value >= 1 ? 1 : value;
|
||||
}
|
||||
|
||||
export type BirthSkyRevealTimeline = Readonly<{
|
||||
/** Painter frame at this moment. */
|
||||
frame: Omit<BirthSkyFrame, "scatter">;
|
||||
/** Opacity of the whole overlay. */
|
||||
layer: number;
|
||||
/** True once the last fade has finished: remove the overlay, show the next step. */
|
||||
done: boolean;
|
||||
}>;
|
||||
|
||||
const SETTLED = { converge: 1, chrome: 1, text: 1 } as const;
|
||||
|
||||
/**
|
||||
* Normal: 0–1.5 s converge (ease-out) with horizon, rings and letters fading
|
||||
* in; 1.5–2.5 s words fade in and hold; 2.5–3 s the layer fades out.
|
||||
* Reduced motion: the finished picture fades in, holds, fades out.
|
||||
*/
|
||||
export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): BirthSkyRevealTimeline {
|
||||
const t = Math.max(0, elapsedMs);
|
||||
const fadeStart = BIRTH_SKY_CONVERGE_MS + BIRTH_SKY_TEXT_MS;
|
||||
const done = t >= BIRTH_SKY_REVEAL_TOTAL_MS;
|
||||
const fadeOut = t >= fadeStart ? 1 - clamp01((t - fadeStart) / BIRTH_SKY_FADE_MS) : 1;
|
||||
if (reducedMotion) {
|
||||
return { frame: SETTLED, layer: Math.min(clamp01(t / REDUCED_FADE_IN_MS), fadeOut), done };
|
||||
}
|
||||
if (t < BIRTH_SKY_CONVERGE_MS) {
|
||||
const progress = t / BIRTH_SKY_CONVERGE_MS;
|
||||
return {
|
||||
frame: { converge: easeOutCubic(progress), chrome: progress, text: 0 },
|
||||
layer: clamp01(t / LAYER_FADE_IN_MS),
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
frame: { converge: 1, chrome: 1, text: clamp01((t - BIRTH_SKY_CONVERGE_MS) / TEXT_FADE_IN_MS) },
|
||||
layer: fadeOut,
|
||||
done,
|
||||
};
|
||||
}
|
||||
@@ -92,6 +92,35 @@ export function skyPoint(altitude: number, azimuth: number): SkyPoint {
|
||||
}
|
||||
|
||||
export const BIRTH_SKY_CENTER: SkyPoint = { x: CENTER_X, y: CENTER_Y };
|
||||
/** Horizon radius in design units. */
|
||||
export const BIRTH_SKY_RADIUS = RADIUS;
|
||||
|
||||
/**
|
||||
* One frame of the first-chart convergence (TASK-birth-sky-followup-20260928 F4).
|
||||
* Omitted, or with every field at 1, the painter draws the exported cover
|
||||
* exactly: positions are only interpolated, alphas only multiplied.
|
||||
*/
|
||||
export type BirthSkyFrame = Readonly<{
|
||||
/** Eased progress: 0 = scattered positions, 1 = true projected positions. */
|
||||
converge: number;
|
||||
/** Horizon circle, altitude rings, direction letters, constellation lines, planet names. */
|
||||
chrome: number;
|
||||
/** Text frame: top label, rules, headline, fact line, footnote, disclaimer. */
|
||||
text: number;
|
||||
/** Scattered start positions keyed by `birthSkyBodyKey`. */
|
||||
scatter: ReadonlyMap<string, SkyPoint>;
|
||||
}>;
|
||||
|
||||
export function birthSkyBodyKey(kind: "star" | "planet", id: number | string): string {
|
||||
return `${kind}:${id}`;
|
||||
}
|
||||
|
||||
/** Pure: t = 0 returns `from`, t = 1 returns `to` exactly (no floating-point residue). */
|
||||
export function interpolateSkyPoint(from: SkyPoint, to: SkyPoint, t: number): SkyPoint {
|
||||
if (t >= 1) return to;
|
||||
if (t <= 0) return from;
|
||||
return { x: from.x * (1 - t) + to.x * t, y: from.y * (1 - t) + to.y * t };
|
||||
}
|
||||
|
||||
function resolvePalette(palette: BirthSkyDrawOptions["palette"]): BirthSkyPalette {
|
||||
return typeof palette === "string" ? BIRTH_SKY_PALETTES[palette] : palette;
|
||||
@@ -111,8 +140,18 @@ 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 {
|
||||
export function drawBirthSky(
|
||||
ctx: BirthSkyCanvas,
|
||||
sky: BirthSkyOk,
|
||||
options: BirthSkyDrawOptions,
|
||||
frame?: BirthSkyFrame,
|
||||
): void {
|
||||
const palette = resolvePalette(options.palette);
|
||||
const chromeAlpha = frame?.chrome ?? 1;
|
||||
const textAlpha = frame?.text ?? 1;
|
||||
const place = (key: string, point: SkyPoint): SkyPoint => (
|
||||
frame ? interpolateSkyPoint(frame.scatter.get(key) ?? point, point, frame.converge) : point
|
||||
);
|
||||
const serif = options.serif ?? true;
|
||||
const footnote = options.footnote === undefined ? birthSkyFootnote(sky) : options.footnote;
|
||||
const unit = options.width / UNIT_WIDTH;
|
||||
@@ -131,7 +170,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
|
||||
|
||||
ctx.strokeStyle = palette.star;
|
||||
ctx.lineWidth = 0.5;
|
||||
ctx.globalAlpha = 0.22;
|
||||
ctx.globalAlpha = 0.22 * chromeAlpha;
|
||||
ctx.setLineDash([1, 4]);
|
||||
for (const ring of [30, 60]) {
|
||||
ctx.beginPath();
|
||||
@@ -145,7 +184,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
|
||||
ctx.globalAlpha = 0.85;
|
||||
for (const star of sky.stars) {
|
||||
if (star.altitude <= 0) continue;
|
||||
const point = skyPoint(star.altitude, star.azimuth);
|
||||
const point = place(birthSkyBodyKey("star", star.hr), 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);
|
||||
@@ -154,7 +193,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
|
||||
|
||||
ctx.strokeStyle = palette.star;
|
||||
ctx.lineWidth = 0.6;
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.globalAlpha = 0.4 * chromeAlpha;
|
||||
for (const line of sky.lines) {
|
||||
for (const stroke of line.strokes) {
|
||||
for (let index = 1; index < stroke.length; index += 1) {
|
||||
@@ -170,8 +209,11 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
|
||||
}
|
||||
|
||||
const planets = sky.planets.filter((planet) => planet.altitude > 0);
|
||||
const planetPoint = (planet: (typeof planets)[number]) => (
|
||||
place(birthSkyBodyKey("planet", planet.id), skyPoint(planet.altitude, planet.azimuth))
|
||||
);
|
||||
for (const planet of planets) {
|
||||
const point = skyPoint(planet.altitude, planet.azimuth);
|
||||
const point = planetPoint(planet);
|
||||
if (planet.id === "sun") {
|
||||
ctx.fillStyle = palette.sun;
|
||||
ctx.globalAlpha = 0.18;
|
||||
@@ -201,7 +243,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
|
||||
ctx.font = font(400, 11, false);
|
||||
const labels: Box[] = [];
|
||||
for (const planet of planets) {
|
||||
const point = skyPoint(planet.altitude, planet.azimuth);
|
||||
const point = planetPoint(planet);
|
||||
const name = BIRTH_SKY_PLANET_NAMES[planet.id];
|
||||
const width = 24;
|
||||
const candidates = planet.id === "sun"
|
||||
@@ -214,20 +256,20 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
|
||||
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.globalAlpha = 0.9 * chromeAlpha;
|
||||
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.globalAlpha = 0.6 * chromeAlpha;
|
||||
ctx.beginPath();
|
||||
ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = palette.inkSecondary;
|
||||
ctx.globalAlpha = 0.9;
|
||||
ctx.globalAlpha = 0.9 * chromeAlpha;
|
||||
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) {
|
||||
@@ -237,7 +279,7 @@ export function drawBirthSky(ctx: BirthSkyCanvas, sky: BirthSkyOk, options: Birt
|
||||
// Text frame (sample C): label, rule, headline … rule, fact, footnote, disclaimer.
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = palette.inkSecondary;
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalAlpha = 1 * textAlpha;
|
||||
ctx.font = font(600, 12, serif);
|
||||
// Tracked by hand (canvas letterSpacing is not in every Safari we support).
|
||||
let cursor = 48;
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
import type { BirthSkyCover } from "./cover.ts";
|
||||
import type { BirthSkyOk } from "./contract.ts";
|
||||
import { fetchBirthSky } from "./cover.ts";
|
||||
|
||||
/**
|
||||
* First-chart reveal timing (TASK-birth-sky-cover-20260928 T6).
|
||||
* First-chart reveal timing (TASK-birth-sky-cover-20260928 T6, convergence
|
||||
* since TASK-birth-sky-followup-20260928 F4).
|
||||
*
|
||||
* After the reader's own birth place is saved, the cover gets one chance: if
|
||||
* it is fetched and painted within `timeoutMs` it is shown, otherwise it is
|
||||
* dropped silently — no error, no retry, and the onboarding flow never waits.
|
||||
* After the reader's own birth place is saved, the reveal gets one chance: if
|
||||
* its data is ready within `timeoutMs` it plays, otherwise it is dropped
|
||||
* silently — no error, no retry, and the onboarding flow never waits.
|
||||
*/
|
||||
export const BIRTH_SKY_REVEAL_TIMEOUT_MS = 3_000;
|
||||
|
||||
export type BirthSkyRevealInput = Readonly<{
|
||||
load: (signal: AbortSignal) => Promise<BirthSkyCover | null>;
|
||||
onReady: (cover: BirthSkyCover) => void;
|
||||
/** A cover that arrived too late; the caller releases its object URL. */
|
||||
onDiscard?: (cover: BirthSkyCover) => void;
|
||||
/** Everything the convergence overlay needs before its first frame. */
|
||||
export type BirthSkyRevealSky = Readonly<{ sky: BirthSkyOk; serif: boolean }>;
|
||||
|
||||
/** Fetch the reader's own sky and wait (bounded) for the heading serif. Null on any failure. */
|
||||
export async function loadBirthSkyRevealSky(signal: AbortSignal): Promise<BirthSkyRevealSky | null> {
|
||||
try {
|
||||
const sky = await fetchBirthSky("self", signal);
|
||||
if (!sky || signal.aborted) return null;
|
||||
const { BIRTH_SKY_TOP_LABEL, birthSkyHeadline } = await import("./sentence.ts");
|
||||
const { waitForHeadingSerif } = await import("./render.ts");
|
||||
const serif = await waitForHeadingSerif(`${birthSkyHeadline(sky)}${BIRTH_SKY_TOP_LABEL}`);
|
||||
return signal.aborted ? null : { sky, serif };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type BirthSkyRevealInput<T> = Readonly<{
|
||||
load: (signal: AbortSignal) => Promise<T | null>;
|
||||
onReady: (value: T) => void;
|
||||
/** A value that arrived too late; the caller releases whatever it holds. */
|
||||
onDiscard?: (value: T) => void;
|
||||
timeoutMs?: number;
|
||||
setTimer?: (callback: () => void, ms: number) => unknown;
|
||||
clearTimer?: (handle: unknown) => void;
|
||||
}>;
|
||||
|
||||
/** Starts one reveal attempt; the returned function cancels it. */
|
||||
export function startBirthSkyReveal(input: BirthSkyRevealInput): () => void {
|
||||
export function startBirthSkyReveal<T>(input: BirthSkyRevealInput<T>): () => void {
|
||||
const setTimer = input.setTimer ?? ((callback: () => void, ms: number) => setTimeout(callback, ms));
|
||||
const clearTimer = input.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
const controller = new AbortController();
|
||||
|
||||
Reference in New Issue
Block a user