Files
Jyotisha/frontend/src/lib/birth-sky/draw.ts
T
jesse-uxandGrok (agent loop) e4e240bf86
Independent Staging Quality Gate / validate (push) Failing after 9m8s
Independent Staging Quality Gate / publish (push) Skipped
启程第一次出现时会动一下(#99)。
建档、起步问题、清单打勾、天空过场和合盘按 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)
2026-10-25 02:17:32 +08:00

348 lines
12 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_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();
}