Files
Jyotisha/frontend/src/lib/birth-sky/converge.ts
T
Jesse_ChenandClaude Opus 5.5 dec36bce01 feat(birth-sky): poster-only dialog, date/time/city caption, slower reveal, paywall on first refused send (P1–P5, BUG-1080)
TASK-birth-sky-polish-20260928.
- P1: chart page dialog shows only the picture on a dimmed stage, 「保存图片」 below, icon × in the corner.
- P2: BFF returns one server-built `caption` (「1994年5月18日 07:40 · 杭州」); printed under the fact line in all three places; adopted time adds 「 · 校正后」.
- P3: convergence 3 s + words 2.5 s + fade 1 s; settled frame still equals the exported cover.
- P4: redeem paywall no longer opens when the starter home appears; first send refused for credits opens it, later ones open billing; convergence dialog portalled to <body>. BUG-1080 investigating (Chrome repro rules out the overlay covering the convergence).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-29 00:03:46 +08:00

148 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = 3_000;
export const BIRTH_SKY_TEXT_MS = 2_500;
export const BIRTH_SKY_FADE_MS = 1_000;
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 = 600;
/** The layer itself comes in quickly so the page does not flash to dark. */
const LAYER_FADE_IN_MS = 400;
/** Reduced motion: no travel, one fade in, a hold, one fade out. */
const REDUCED_FADE_IN_MS = 600;
/** 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 (TASK-birth-sky-polish-20260928 P3, slowed from 1.5 + 1 + 0.5 s):
* 0–3 s converge (ease-out) with horizon, rings and letters fading in;
* 3–5.5 s words fade in and hold; 5.5–6.5 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,
};
}