feat(birth-sky): the chart page replays the convergence, holds on the poster, shares top right (M4)
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
5e97db4077
commit
60b5542b7d
@@ -23,3 +23,47 @@
|
||||
.birth-sky-converge canvas { display: block; }
|
||||
@supports (height: 1dvh) { .birth-sky-converge { height: 100dvh; } }
|
||||
@media print { .birth-sky-converge { display: none !important; } }
|
||||
/* Chart page replay controls (TASK-mobile-chart-and-confirmed-edit-20260929 M4):
|
||||
× top left, 「分享」 top right, on the night floor in its ink (DESIGN §17:
|
||||
#1a1a19 / #f2f0ea — the picture is always night). 44px targets; the first-
|
||||
chart reveal passes no controls and stays button-free. */
|
||||
.birth-sky-controls {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
padding: max(var(--space-2), env(safe-area-inset-top)) max(var(--space-2), env(safe-area-inset-right)) 0 max(var(--space-2), env(safe-area-inset-left));
|
||||
pointer-events: none;
|
||||
}
|
||||
.birth-sky-control {
|
||||
pointer-events: auto;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-3);
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: #f2f0ea;
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.birth-sky-control:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
|
||||
.birth-sky-control:active { transform: scale(.96); }
|
||||
.birth-sky-control:focus-visible { outline: 2px solid #f2f0ea; outline-offset: 2px; }
|
||||
.birth-sky-control svg { width: 20px; height: 20px; }
|
||||
.birth-sky-share { border: 1px solid color-mix(in srgb, #f2f0ea 42%, transparent); }
|
||||
.birth-sky-control-error {
|
||||
flex-basis: 100%;
|
||||
margin: var(--space-2) 0 0;
|
||||
text-align: right;
|
||||
color: #b3afa4;
|
||||
font-size: var(--type-body-sm);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import "./birth-sky-converge.css";
|
||||
|
||||
import { birthSkyRevealAt, birthSkyScatter } from "@/lib/birth-sky/converge";
|
||||
import { birthSkyHoldAtMs, birthSkyRevealAt, birthSkyScatter, type BirthSkyRevealMode } 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";
|
||||
@@ -17,13 +18,23 @@ export function birthSkyConvergeSize(viewportWidth: number, viewportHeight: numb
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The star convergence. Every frame is `drawBirthSky` with interpolated
|
||||
* positions, so the settled frame is the exported cover.
|
||||
*
|
||||
* - `reveal` (first chart, 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.
|
||||
* - `hold` (chart page, TASK-mobile-chart-and-confirmed-edit-20260929 M4): the
|
||||
* same converge and words, then it stays on the settled frame. A tap skips to
|
||||
* that frame; Esc closes. The caller's `controls` (close, share) sit on top.
|
||||
*/
|
||||
export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky; onDone: () => void }) {
|
||||
export function BirthSkyConverge({ reveal, onDone, mode = "reveal", controls }: {
|
||||
reveal: BirthSkyRevealSky;
|
||||
onDone: () => void;
|
||||
mode?: BirthSkyRevealMode;
|
||||
controls?: ReactNode;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
@@ -40,7 +51,18 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
|
||||
let frameHandle = 0;
|
||||
let started: number | null = null;
|
||||
let finished = false;
|
||||
let last: ReturnType<typeof birthSkyRevealAt> | null = null;
|
||||
|
||||
const draw = (moment: ReturnType<typeof birthSkyRevealAt>) => {
|
||||
last = moment;
|
||||
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 });
|
||||
};
|
||||
const fit = () => {
|
||||
const size = birthSkyConvergeSize(window.innerWidth, window.innerHeight);
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
@@ -48,6 +70,8 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
|
||||
surface.style.height = `${size.height}px`;
|
||||
surface.width = Math.round(size.width * ratio);
|
||||
surface.height = Math.round(size.height * ratio);
|
||||
// Resizing clears the canvas; a held frame has no next tick to repaint it.
|
||||
if (last?.held) draw(last);
|
||||
};
|
||||
const finish = () => {
|
||||
if (finished) return;
|
||||
@@ -58,17 +82,20 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
|
||||
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 });
|
||||
const moment = birthSkyRevealAt(now - started, reduced, mode);
|
||||
draw(moment);
|
||||
if (moment.done) finish();
|
||||
else frameHandle = requestAnimationFrame(tick);
|
||||
else if (!moment.held) frameHandle = requestAnimationFrame(tick);
|
||||
};
|
||||
// `hold`: a tap on the stage (not on a control) jumps to the settled frame.
|
||||
const skip = (event: Event) => {
|
||||
if (finished || last?.held) return;
|
||||
if (event.target instanceof Element && event.target.closest(".birth-sky-controls")) return;
|
||||
cancelAnimationFrame(frameHandle);
|
||||
started = performance.now() - birthSkyHoldAtMs(reduced);
|
||||
frameHandle = requestAnimationFrame(tick);
|
||||
};
|
||||
const onPointerDown = mode === "hold" ? skip : finish;
|
||||
const cancel = (event: Event) => {
|
||||
event.preventDefault();
|
||||
finish();
|
||||
@@ -78,18 +105,18 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
|
||||
layer.style.opacity = "0";
|
||||
layer.showModal();
|
||||
layer.addEventListener("cancel", cancel);
|
||||
layer.addEventListener("pointerdown", finish);
|
||||
layer.addEventListener("pointerdown", onPointerDown);
|
||||
window.addEventListener("resize", fit);
|
||||
frameHandle = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
finished = true;
|
||||
cancelAnimationFrame(frameHandle);
|
||||
layer.removeEventListener("cancel", cancel);
|
||||
layer.removeEventListener("pointerdown", finish);
|
||||
layer.removeEventListener("pointerdown", onPointerDown);
|
||||
window.removeEventListener("resize", fit);
|
||||
layer.close();
|
||||
};
|
||||
}, [reveal, onDone]);
|
||||
}, [reveal, onDone, mode]);
|
||||
|
||||
// Portalled to <body>, outside SidebarInset, which is inert while any
|
||||
// account dialog or the redeem paywall is open (BUG-968, BUG-1080).
|
||||
@@ -105,6 +132,7 @@ export function BirthSkyConverge({ reveal, onDone }: { reveal: BirthSkyRevealSky
|
||||
role="img"
|
||||
aria-label={`${birthSkyHeadline(reveal.sky)}${birthSkyFactLine(reveal.sky)}。`}
|
||||
/>
|
||||
{controls}
|
||||
</dialog>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import "./birth-sky.css";
|
||||
|
||||
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;
|
||||
}>;
|
||||
|
||||
/** Accessible name of the stage; the picture itself carries the words. */
|
||||
const BIRTH_SKY_DIALOG_LABEL = "那一刻的天空";
|
||||
|
||||
/**
|
||||
* Native `<dialog>` that shows only the picture on a dimmed stage, sized to
|
||||
* the viewport, with 「保存图片」 under it. 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 }: 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)}。`;
|
||||
// Poster only (TASK-birth-sky-polish-20260928 P1): no panel, no title bar.
|
||||
// The dialog is the dimmed stage; a click on it (not on the picture or a
|
||||
// button) closes, as do Esc and the icon ×.
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="birth-sky-dialog"
|
||||
aria-label={BIRTH_SKY_DIALOG_LABEL}
|
||||
onCancel={(event) => { event.preventDefault(); onClose(); }}
|
||||
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
|
||||
>
|
||||
<button className="birth-sky-close" type="button" onClick={onClose} aria-label="关闭">
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
{/* 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 className="birth-sky-actions">
|
||||
{error ? <p role="alert">{error}</p> : null}
|
||||
<button className="birth-sky-save" type="button" onClick={() => void save()}>保存图片</button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,8 @@ 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),
|
||||
const BirthSkyReplay = dynamic(
|
||||
() => import("./birth-sky-replay").then((module) => module.BirthSkyReplay),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
@@ -15,7 +15,8 @@ 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.
|
||||
* painted; the replay (convergence, controls and CSS) is a lazy chunk opened on
|
||||
* click, and every open plays it again (TASK-mobile-chart-and-confirmed-edit-20260929 M4).
|
||||
*/
|
||||
export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -24,7 +25,7 @@ export function BirthSkyEntry({ cover }: { cover: BirthSkyCover }) {
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
{BIRTH_SKY_ENTRY_LABEL}
|
||||
</Button>
|
||||
{open ? <BirthSkyDialog cover={cover} onClose={() => setOpen(false)} /> : null}
|
||||
{open ? <BirthSkyReplay cover={cover} onClose={() => setOpen(false)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Share, X } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
|
||||
import { BirthSkyConverge } from "./birth-sky-converge";
|
||||
|
||||
import type { BirthSkyCover } from "@/lib/birth-sky/cover";
|
||||
import { saveBirthSkyImage } from "@/lib/birth-sky/export";
|
||||
|
||||
/** VOICE.md: the one control that hands the poster out; share sheet on a phone, download elsewhere. */
|
||||
export const BIRTH_SKY_SHARE_LABEL = "分享";
|
||||
export const BIRTH_SKY_SHARE_FAILED = "没能分享,再试一次。";
|
||||
|
||||
/**
|
||||
* The chart page's 「那一刻的天空」 (TASK-mobile-chart-and-confirmed-edit-20260929
|
||||
* M4). Every open replays the star convergence and stays on the settled frame,
|
||||
* which is the poster itself. × (top left) or Esc closes; 「分享」 (top right)
|
||||
* hands out the painted PNG through the share sheet, or downloads it where
|
||||
* there is none. The PNG was painted before the entry button appeared, so
|
||||
* sharing never waits.
|
||||
*/
|
||||
export function BirthSkyReplay({ cover, onClose }: { cover: BirthSkyCover; onClose: () => void }) {
|
||||
const reveal = useMemo(() => ({ sky: cover.sky, serif: cover.serif }), [cover.sky, cover.serif]);
|
||||
const sharing = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function share() {
|
||||
if (sharing.current) return;
|
||||
sharing.current = true;
|
||||
setError(null);
|
||||
try {
|
||||
await saveBirthSkyImage(cover.blob);
|
||||
} catch {
|
||||
setError(BIRTH_SKY_SHARE_FAILED);
|
||||
} finally {
|
||||
sharing.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BirthSkyConverge
|
||||
reveal={reveal}
|
||||
mode="hold"
|
||||
onDone={onClose}
|
||||
controls={(
|
||||
<div className="birth-sky-controls">
|
||||
<button className="birth-sky-control" type="button" onClick={onClose} aria-label="关闭">
|
||||
<X aria-hidden="true" />
|
||||
</button>
|
||||
<button className="birth-sky-control birth-sky-share" type="button" onClick={() => void share()}>
|
||||
<Share aria-hidden="true" />
|
||||
<span>{BIRTH_SKY_SHARE_LABEL}</span>
|
||||
</button>
|
||||
{error ? <p className="birth-sky-control-error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/* 「那一刻的天空」 dialog (TASK-birth-sky-cover-20260928; poster only since
|
||||
TASK-birth-sky-polish-20260928 P1). Loaded with the lazy dialog chunk only,
|
||||
never on first paint. No panel and no title bar: the dialog is a dimmed
|
||||
stage showing the painted picture, 「保存图片」 under it and an icon × in the
|
||||
corner. The stage and controls use the night palette (DESIGN §17: the dark
|
||||
theme floor #1a1a19 and ink #f2f0ea), because the picture is always night. */
|
||||
.birth-sky-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: var(--space-4);
|
||||
padding-top: max(var(--space-4), env(safe-area-inset-top));
|
||||
padding-bottom: max(var(--space-4), env(safe-area-inset-bottom));
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #f2f0ea;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.birth-sky-dialog[open] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.birth-sky-dialog::backdrop { background: color-mix(in srgb, #1a1a19 90%, transparent); }
|
||||
.birth-sky-image {
|
||||
display: block;
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
/* Room for the save row (44px) + gap + stage padding. */
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 44px - 3 * var(--space-4) - env(safe-area-inset-top) - env(safe-area-inset-bottom));
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: var(--radius-md);
|
||||
-webkit-touch-callout: default;
|
||||
}
|
||||
@supports (height: 1dvh) {
|
||||
.birth-sky-dialog { height: 100dvh; }
|
||||
.birth-sky-image { max-height: calc(100dvh - 44px - 3 * var(--space-4) - env(safe-area-inset-top) - env(safe-area-inset-bottom)); }
|
||||
}
|
||||
.birth-sky-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.birth-sky-actions p { margin: 0; text-align: center; color: #b3afa4; font-size: var(--type-body-sm); }
|
||||
.birth-sky-save {
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 1px solid color-mix(in srgb, #f2f0ea 42%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: #f2f0ea;
|
||||
font: inherit;
|
||||
font-size: var(--type-body-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out, transform 120ms ease-out;
|
||||
}
|
||||
.birth-sky-save:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
|
||||
.birth-sky-save:active { transform: scale(.96); }
|
||||
.birth-sky-close {
|
||||
position: absolute;
|
||||
top: max(var(--space-2), env(safe-area-inset-top));
|
||||
right: var(--space-2);
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: #f2f0ea;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.birth-sky-close:hover { background: color-mix(in srgb, #f2f0ea 10%, transparent); }
|
||||
.birth-sky-close svg { width: 20px; height: 20px; }
|
||||
.birth-sky-save:focus-visible, .birth-sky-close:focus-visible { outline: 2px solid #f2f0ea; outline-offset: 2px; }
|
||||
@media print { .birth-sky-dialog { display: none !important; } }
|
||||
@@ -111,10 +111,25 @@ export type BirthSkyRevealTimeline = Readonly<{
|
||||
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. */
|
||||
/** True once the last fade has finished: remove the overlay, show the next step. Never in `hold`. */
|
||||
done: boolean;
|
||||
/** `hold` only: the settled frame is on screen and stays; no more frames are needed. */
|
||||
held: boolean;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* `reveal`: the first-chart overlay — converge, words, fade out, done.
|
||||
* `hold` (TASK-mobile-chart-and-confirmed-edit-20260929 M4): the chart page's
|
||||
* 「那一刻的天空」 — the same converge and words, then it stops on the settled
|
||||
* frame (the exported poster) and never fades.
|
||||
*/
|
||||
export type BirthSkyRevealMode = "reveal" | "hold";
|
||||
|
||||
/** `hold`: elapsed time at which the settled frame is fully on screen (words faded in). */
|
||||
export function birthSkyHoldAtMs(reducedMotion: boolean): number {
|
||||
return reducedMotion ? REDUCED_FADE_IN_MS : BIRTH_SKY_CONVERGE_MS + TEXT_FADE_IN_MS;
|
||||
}
|
||||
|
||||
const SETTLED = { converge: 1, chrome: 1, text: 1 } as const;
|
||||
|
||||
/**
|
||||
@@ -123,13 +138,22 @@ const SETTLED = { converge: 1, chrome: 1, text: 1 } as const;
|
||||
* 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 {
|
||||
export function birthSkyRevealAt(
|
||||
elapsedMs: number,
|
||||
reducedMotion: boolean,
|
||||
mode: BirthSkyRevealMode = "reveal",
|
||||
): BirthSkyRevealTimeline {
|
||||
const t = Math.max(0, elapsedMs);
|
||||
if (mode === "hold") {
|
||||
const held = t >= birthSkyHoldAtMs(reducedMotion);
|
||||
if (reducedMotion) return { frame: SETTLED, layer: clamp01(t / REDUCED_FADE_IN_MS), done: false, held };
|
||||
if (held) return { frame: SETTLED, layer: 1, done: false, held };
|
||||
}
|
||||
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;
|
||||
const fadeOut = mode === "hold" ? 1 : 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 };
|
||||
return { frame: SETTLED, layer: Math.min(clamp01(t / REDUCED_FADE_IN_MS), fadeOut), done, held: false };
|
||||
}
|
||||
if (t < BIRTH_SKY_CONVERGE_MS) {
|
||||
const progress = t / BIRTH_SKY_CONVERGE_MS;
|
||||
@@ -137,11 +161,13 @@ export function birthSkyRevealAt(elapsedMs: number, reducedMotion: boolean): Bir
|
||||
frame: { converge: easeOutCubic(progress), chrome: progress, text: 0 },
|
||||
layer: clamp01(t / LAYER_FADE_IN_MS),
|
||||
done: false,
|
||||
held: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
frame: { converge: 1, chrome: 1, text: clamp01((t - BIRTH_SKY_CONVERGE_MS) / TEXT_FADE_IN_MS) },
|
||||
layer: fadeOut,
|
||||
done,
|
||||
done: mode === "hold" ? false : done,
|
||||
held: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export type BirthSkyCover = Readonly<{
|
||||
sky: BirthSkyOk;
|
||||
blob: Blob;
|
||||
url: string;
|
||||
/** Whether the PNG was painted with the heading serif; the live replay draws the same. */
|
||||
serif: boolean;
|
||||
}>;
|
||||
|
||||
export async function fetchBirthSky(subjectId: string, signal?: AbortSignal): Promise<BirthSkyOk | null> {
|
||||
@@ -29,10 +31,10 @@ export async function loadBirthSkyCover(key: string, subjectId: string, signal?:
|
||||
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");
|
||||
const { paintBirthSkyPng } = await import("./render.ts");
|
||||
const { blob, serif } = await paintBirthSkyPng(sky, "night");
|
||||
if (signal?.aborted) return null;
|
||||
return { key, sky, blob, url: URL.createObjectURL(blob) };
|
||||
return { key, sky, blob, url: URL.createObjectURL(blob), serif };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -39,11 +39,15 @@ export async function waitForHeadingSerif(
|
||||
}
|
||||
}
|
||||
|
||||
/** Paint the cover on an off-screen canvas and return the PNG. Browser only. */
|
||||
export async function renderBirthSkyPng(
|
||||
/**
|
||||
* Paint the cover on an off-screen canvas. Returns the PNG and whether the
|
||||
* heading serif made it in time, so a live canvas can draw the identical
|
||||
* picture (TASK-mobile-chart-and-confirmed-edit-20260929 M4). Browser only.
|
||||
*/
|
||||
export async function paintBirthSkyPng(
|
||||
sky: BirthSkyOk,
|
||||
palette: BirthSkyPaletteName = "night",
|
||||
): Promise<Blob> {
|
||||
): Promise<Readonly<{ blob: Blob; serif: boolean }>> {
|
||||
const serif = await waitForHeadingSerif(`${birthSkyHeadline(sky)}${BIRTH_SKY_TOP_LABEL}`);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = BIRTH_SKY_EXPORT_WIDTH;
|
||||
@@ -56,5 +60,13 @@ export async function renderBirthSkyPng(
|
||||
height: BIRTH_SKY_EXPORT_HEIGHT,
|
||||
serif,
|
||||
});
|
||||
return canvasToPngBlob(canvas);
|
||||
return { blob: await canvasToPngBlob(canvas), serif };
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
return (await paintBirthSkyPng(sky, palette)).blob;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user