建档、起步问题、清单打勾、天空过场和合盘按 T7 播放一次。减少动态效果时只剩淡入,没有星点和闪烁。Motion 仍按需加载,首屏 gzip 631199,相对 627046 约 +0.66%。 自测:tsc --noEmit、npm run lint(0 error)、定向测试 58 项 fail 0、npm run build(/ 仍 Static)。 Co-Authored-By: Grok (agent loop)
348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import type { BirthSkyOk } from "./contract.ts";
|
||
import {
|
||
BIRTH_SKY_DISCLAIMER,
|
||
BIRTH_SKY_PLANET_NAMES,
|
||
BIRTH_SKY_TOP_LABEL,
|
||
birthSkyFactLine,
|
||
birthSkyCaptionLine,
|
||
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;
|
||
/** 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 };
|
||
/** 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, caption, disclaimer. */
|
||
text: number;
|
||
/** Scattered start positions keyed by `birthSkyBodyKey`. */
|
||
scatter: ReadonlyMap<string, SkyPoint>;
|
||
/**
|
||
* Optional entrance beats. Omitted, or left at identity
|
||
* (starLight 1, planetLight 1, ascendantLinks 0, textLines [1, 1],
|
||
* convergeOverride 1 while converge is already 1), the painter matches
|
||
* the exported cover call for call.
|
||
*/
|
||
starLight?: number;
|
||
planetLight?: number;
|
||
ascendantLinks?: number;
|
||
textLines?: readonly [number, number];
|
||
convergeOverride?: number;
|
||
}>;
|
||
|
||
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;
|
||
}
|
||
|
||
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,
|
||
frame?: BirthSkyFrame,
|
||
): void {
|
||
const palette = resolvePalette(options.palette);
|
||
const chromeAlpha = frame?.chrome ?? 1;
|
||
const textAlpha = frame?.text ?? 1;
|
||
const starLight = frame?.starLight ?? 1;
|
||
const planetLight = frame?.planetLight ?? 1;
|
||
const place = (key: string, point: SkyPoint): SkyPoint => (
|
||
frame
|
||
? interpolateSkyPoint(frame.scatter.get(key) ?? point, point, frame.convergeOverride ?? frame.converge)
|
||
: point
|
||
);
|
||
const serif = options.serif ?? true;
|
||
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 * chromeAlpha;
|
||
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 * starLight;
|
||
for (const star of sky.stars) {
|
||
if (star.altitude <= 0) continue;
|
||
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);
|
||
ctx.fill();
|
||
}
|
||
|
||
ctx.strokeStyle = palette.star;
|
||
ctx.lineWidth = 0.6;
|
||
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) {
|
||
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);
|
||
const planetPoint = (planet: (typeof planets)[number]) => (
|
||
place(birthSkyBodyKey("planet", planet.id), skyPoint(planet.altitude, planet.azimuth))
|
||
);
|
||
const bodyAlpha = (index: number) => (
|
||
planetLight >= 1 ? 1 : Math.min(1, Math.max(0, planetLight * planets.length - index))
|
||
);
|
||
for (const [index, planet] of planets.entries()) {
|
||
const point = planetPoint(planet);
|
||
const lit = bodyAlpha(index);
|
||
if (planet.id === "sun") {
|
||
ctx.fillStyle = palette.sun;
|
||
ctx.globalAlpha = 0.18 * lit;
|
||
ctx.beginPath();
|
||
ctx.arc(point.x, point.y, 16, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.globalAlpha = lit;
|
||
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 = lit;
|
||
ctx.beginPath();
|
||
ctx.arc(point.x, point.y, 3.2, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
const linkAlpha = frame?.ascendantLinks ?? 0;
|
||
if (linkAlpha > 0) {
|
||
const east = skyPoint(0, 90);
|
||
ctx.strokeStyle = palette.star;
|
||
ctx.lineWidth = 0.7;
|
||
for (const [index, planet] of planets.entries()) {
|
||
const lit = bodyAlpha(index);
|
||
if (lit <= 0) continue;
|
||
const point = planetPoint(planet);
|
||
ctx.globalAlpha = 0.55 * linkAlpha * lit;
|
||
ctx.beginPath();
|
||
ctx.moveTo(east.x, east.y);
|
||
ctx.lineTo(point.x, point.y);
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
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 = planetPoint(planet);
|
||
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 * 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 * chromeAlpha;
|
||
ctx.beginPath();
|
||
ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
ctx.fillStyle = palette.inkSecondary;
|
||
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) {
|
||
ctx.fillText(text, CENTER_X + dx * (RADIUS + 18), CENTER_Y + dy * (RADIUS + 18) + 4);
|
||
}
|
||
|
||
// Text frame (sample C): label, rule, headline … rule, fact, caption, disclaimer.
|
||
ctx.textAlign = "left";
|
||
ctx.fillStyle = palette.inkSecondary;
|
||
ctx.globalAlpha = textAlpha;
|
||
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.globalAlpha = textAlpha * (frame?.textLines?.[0] ?? 1);
|
||
ctx.fillText(birthSkyHeadline(sky), 48, 146, 504);
|
||
|
||
ctx.font = font(600, 15, false);
|
||
ctx.globalAlpha = textAlpha * (frame?.textLines?.[1] ?? 1);
|
||
ctx.fillText(birthSkyFactLine(sky), 48, 686, 504);
|
||
ctx.globalAlpha = textAlpha;
|
||
|
||
// Birth caption (TASK-birth-sky-polish-20260928 P2), where the footnote used to be.
|
||
ctx.fillStyle = palette.inkSecondary;
|
||
ctx.font = font(400, 12.5, false);
|
||
ctx.fillText(birthSkyCaptionLine(sky), 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();
|
||
}
|