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
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
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,
|
||
};
|
||
}
|