From 4200e1d72a1295ae8510e1acd4650c8fb5c5a2fa Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:26 -0800 Subject: [PATCH 01/17] feat(og): software canvas, PNG encoder and original stroke font Zero-dependency Canvas2D subset with a CPU deadline, a filtered RGB PNG encoder over node:zlib, and an original monoline font limited to ASCII-safe glyphs for untrusted repository text. Co-Authored-By: Claude Opus 5.5 --- src/og/font.ts | 202 +++++++++++++++++ src/og/png.ts | 68 ++++++ src/og/raster.ts | 567 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 837 insertions(+) create mode 100644 src/og/font.ts create mode 100644 src/og/png.ts create mode 100644 src/og/raster.ts diff --git a/src/og/font.ts b/src/og/font.ts new file mode 100644 index 0000000..cf0c5d5 --- /dev/null +++ b/src/og/font.ts @@ -0,0 +1,202 @@ +import type { SoftCanvas, TextEngine } from "./raster"; + +/** Original monoline geometric stroke font for server-rendered previews. + * Units: cap height 10, x-height 7, baseline y = 0, y grows downward. + * Commands: M x y | L x y | A cx cy rx ry fromDeg toDeg (toDeg < fromDeg = anticlockwise). */ +const G: Record = { + " ": [3.4, ""], + A: [7.4, "M0 0 L3.7 -10 L7.4 0 M1.3 -3.4 L6.1 -3.4"], + B: [6.6, "M0 0 L0 -10 L3.5 -10 A3.5 -7.6 2.4 2.4 -90 90 L0 -5.2 M3.5 -5.2 L3.9 -5.2 A3.9 -2.6 2.6 2.6 -90 90 L0 0"], + C: [7.6, "A4.6 -5 4.4 5 -40 -320"], + D: [7.2, "M0 0 L0 -10 L2.4 -10 A2.4 -5 4.8 5 -90 90 L0 0"], + E: [6, "M6 -10 L0 -10 L0 0 L6 0 M0 -5.1 L5 -5.1"], + F: [5.8, "M5.8 -10 L0 -10 L0 0 M0 -5.1 L4.9 -5.1"], + G: [8, "M5.2 -4.6 L8.8 -4.6 L8.8 -2.6 M8.8 -4.6 A4.4 -5 4.4 5 0 -320"], + H: [7, "M0 0 L0 -10 M7 0 L7 -10 M0 -5.1 L7 -5.1"], + I: [0, "M0 0 L0 -10"], + J: [5, "M5 -10 L5 -2.6 A2.5 -2.6 2.5 2.6 0 180"], + K: [6.6, "M0 0 L0 -10 M6.4 -10 L0 -3.8 M2.3 -6 L6.6 0"], + L: [5.6, "M0 -10 L0 0 L5.6 0"], + M: [8.6, "M0 0 L0 -10 L4.3 -2.6 L8.6 -10 L8.6 0"], + N: [7.2, "M0 0 L0 -10 L7.2 0 L7.2 -10"], + O: [9.2, "A4.6 -5 4.6 5 0 360"], + P: [6.4, "M0 0 L0 -10 L3.6 -10 A3.6 -7.3 2.7 2.7 -90 90 L0 -4.6"], + Q: [9.2, "A4.6 -5 4.6 5 0 360 M5.8 -2.6 L9 0.6"], + R: [6.6, "M0 0 L0 -10 L3.6 -10 A3.6 -7.3 2.7 2.7 -90 90 L0 -4.6 M3.4 -4.6 L6.6 0"], + S: [6.6, "A3.3 -7.5 3 2.5 -25 -270 A3.3 -2.5 3.3 2.5 -90 155"], + T: [7, "M0 -10 L7 -10 M3.5 -10 L3.5 0"], + U: [6.8, "M0 -10 L0 -3.4 A3.4 -3.4 3.4 3.4 180 0 L6.8 -10"], + V: [7.4, "M0 -10 L3.7 0 L7.4 -10"], + W: [10, "M0 -10 L2.5 0 L5 -7.8 L7.5 0 L10 -10"], + X: [7, "M0 -10 L7 0 M7 -10 L0 0"], + Y: [7, "M0 -10 L3.5 -4.8 L7 -10 M3.5 -4.8 L3.5 0"], + Z: [6.6, "M0 -10 L6.6 -10 L0 0 L6.6 0"], + a: [6.6, "M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360 M6.6 -7 L6.6 0"], + b: [6.6, "M0 -10.4 L0 0 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"], + c: [6, "A3.4 -3.5 3.3 3.5 -40 -320"], + d: [6.6, "M6.6 -10.4 L6.6 0 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"], + e: [6.6, "M0.1 -3.6 L6.6 -3.6 A3.3 -3.5 3.3 3.5 0 -315"], + f: [4.6, "M5.2 -9.6 A3.4 -8.4 1.9 1.9 -40 -180 L1.5 0 M0 -6.8 L4.4 -6.8"], + g: [6.6, "M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360 M6.6 -7 L6.6 0.4 A3.3 0.4 3.3 2.8 0 150"], + h: [6.4, "M0 -10.4 L0 0 M0 -3.8 A3.2 -3.8 3.2 3.2 180 360 L6.4 0"], + i: [0, "M0 -7 L0 0 M0 -9.7 L0 -9.65"], + j: [2, "M2 -7 L2 1.2 A0.2 1.2 1.8 1.9 0 160 M2 -9.7 L2 -9.65"], + k: [5.8, "M0 -10.4 L0 0 M5.6 -7 L0 -2.6 M2 -4.2 L5.8 0"], + l: [0, "M0 -10.4 L0 0"], + m: [10, "M0 -7 L0 0 M0 -4.2 A2.5 -4.2 2.5 2.8 180 360 L5 0 M5 -4.2 A7.5 -4.2 2.5 2.8 180 360 L10 0"], + n: [6.4, "M0 -7 L0 0 M0 -3.8 A3.2 -3.8 3.2 3.2 180 360 L6.4 0"], + o: [7, "A3.5 -3.5 3.5 3.5 0 360"], + p: [6.6, "M0 -7 L0 3.2 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"], + q: [6.6, "M6.6 -7 L6.6 3.2 M6.6 -3.5 A3.3 -3.5 3.3 3.5 0 360"], + r: [4.4, "M0 -7 L0 0 M0 -3.6 A3.4 -3.6 3.4 3.4 180 285"], + s: [5.6, "A2.8 -5.25 2.6 1.75 -25 -270 A2.8 -1.75 2.8 1.75 -90 155"], + t: [4.6, "M1.6 -9.2 L1.6 -1.8 A3.4 -1.8 1.8 1.8 180 90 L4.6 0 M0 -7 L4.4 -7"], + u: [6.4, "M0 -7 L0 -3.2 A3.2 -3.2 3.2 3.2 180 0 M6.4 -7 L6.4 0"], + v: [6.2, "M0 -7 L3.1 0 L6.2 -7"], + w: [9, "M0 -7 L2.25 0 L4.5 -5.4 L6.75 0 L9 -7"], + x: [5.8, "M0 -7 L5.8 0 M5.8 -7 L0 0"], + y: [6.2, "M0 -7 L3.1 0 M6.2 -7 L2.3 2.4 L1.5 3.2 L0.3 3.2"], + z: [5.8, "M0 -7 L5.8 -7 L0 0 L5.8 0"], + "0": [6.6, "A3.3 -5 3.3 5 0 360"], + "1": [3.4, "M0 -7.8 L2.8 -10 L2.8 0"], + "2": [6.2, "A3.1 -7.1 3 2.9 -165 35 L0 0 L6.4 0"], + "3": [6.2, "A3.1 -7.6 2.8 2.4 -160 90 A3.1 -2.6 3.2 2.6 -90 155"], + "4": [6.8, "M5.2 0 L5.2 -10 L0 -3 L6.8 -3"], + "5": [6.2, "M5.8 -10 L1.1 -10 L0.6 -5.6 A3.1 -3.1 3.1 3.1 -125 150"], + "6": [6.4, "A3.2 -3.2 3.2 3.2 0 360 M0.3 -4.4 L4.6 -10"], + "7": [6.2, "M0 -10 L6.2 -10 L2 0"], + "8": [6.4, "M5.7 -7.5 A3.2 -7.5 2.5 2.5 0 360 M6.4 -2.55 A3.2 -2.55 3.2 2.55 0 360"], + "9": [6.4, "A3.2 -6.8 3.2 3.2 0 360 M6.1 -5.6 L1.8 0"], + ".": [0, "M0 0 L0 -0.05"], + ",": [0.6, "M0.6 -0.3 L-0.2 2"], + ":": [0, "M0 0 L0 -0.05 M0 -6 L0 -6.05"], + ";": [0.6, "M0.6 -0.3 L-0.2 2 M0.6 -6 L0.6 -6.05"], + "-": [3.8, "M0 -3.9 L3.8 -3.9"], + _: [6, "M0 1.6 L6 1.6"], + "/": [5, "M0 1 L5 -10.6"], + "\\": [5, "M0 -10.6 L5 1"], + "'": [0, "M0 -10 L0 -7.4"], + '"': [2.4, "M0 -10 L0 -7.4 M2.4 -10 L2.4 -7.4"], + "`": [1.6, "M0 -10.4 L1.6 -8.6"], + "!": [0, "M0 -10 L0 -3.2 M0 0 L0 -0.05"], + "?": [5.6, "A2.8 -7.4 2.8 2.6 -170 70 L2.8 -3 M2.8 0 L2.8 -0.05"], + "(": [2.6, "A3.4 -3.8 3.4 7.2 -110 -250"], + ")": [2.6, "A-0.8 -3.8 3.4 7.2 -70 70"], + "[": [2.6, "M2.6 -10.6 L0 -10.6 L0 2.2 L2.6 2.2"], + "]": [2.6, "M0 -10.6 L2.6 -10.6 L2.6 2.2 L0 2.2"], + "{": [3.2, "M3.2 -10.6 L1.8 -10.6 L1.6 -4.6 L0 -4.2 L1.6 -3.8 L1.8 2.2 L3.2 2.2"], + "}": [3.2, "M0 -10.6 L1.4 -10.6 L1.6 -4.6 L3.2 -4.2 L1.6 -3.8 L1.4 2.2 L0 2.2"], + "<": [5.4, "M5.4 -8.2 L0 -4.2 L5.4 -0.2"], + ">": [5.4, "M0 -8.2 L5.4 -4.2 L0 -0.2"], + "=": [5.6, "M0 -5.6 L5.6 -5.6 M0 -2.6 L5.6 -2.6"], + "+": [5.8, "M0 -4.1 L5.8 -4.1 M2.9 -7 L2.9 -1.2"], + "*": [5, "M2.5 -10 L2.5 -5 M0.2 -8.8 L4.8 -6.2 M4.8 -8.8 L0.2 -6.2"], + "#": [7, "M2.4 -10 L1.4 0 M5.6 -10 L4.6 0 M0.4 -6.6 L7 -6.6 M0 -3.2 L6.6 -3.2"], + "%": [8, "A1.9 -7.8 1.9 2.1 0 360 M8 -2.1 A6.1 -2.1 1.9 2.1 0 360 M7.2 -10 L0.8 0"], + "&": [7.4, "M7.4 0 L1.6 -6.6 A3.3 -8.1 1.9 1.9 -150 30 L0.8 -3.6 A3.3 -2.5 2.7 2.5 -150 30 L7 -4"], + "@": [10, "M6.6 -3.4 A5 -4.4 1.8 2.1 0 360 M6.6 -6.4 L6.6 -2.8 A8 -2.8 1.4 1.4 180 90 A5 -4.4 5 5.4 20 340"], + $: [6.2, "A3.1 -6.9 2.8 2 -25 -270 A3.1 -2.9 3.1 2 -90 155 M3.1 -10.6 L3.1 0.6"], + "^": [5, "M0 -6.6 L2.5 -10 L5 -6.6"], + "~": [6, "M0 -3.6 L1.5 -4.8 L4.5 -3.2 L6 -4.4"], + "|": [0, "M0 -10.6 L0 2.2"], + "·": [0, "M0 -4 L0 -4.05"], + "…": [8, "M0 0 L0 -0.05 M4 0 L4 -0.05 M8 0 L8 -0.05"], + "★": [9, "M4.5 -10.4 L5.7 -6.9 L9.2 -6.9 L6.4 -4.7 L7.5 -1 L4.5 -3.2 L1.5 -1 L2.6 -4.7 L-0.2 -6.9 L3.3 -6.9 L4.5 -10.4"], + "•": [0, "M0 -4 L0 -4.05"], +}; +type Cmd = ["M" | "L", number, number] | ["A", number, number, number, number, number, number]; +const parsed = new Map(); +function glyph(ch: string) { + const hit = parsed.get(ch); + if (hit) return hit; + const def = G[ch]; + if (!def) return null; + const tokens = def[1].split(/\s+/).filter(Boolean); + const cmds: Cmd[] = []; + for (let i = 0; i < tokens.length; ) { + const t = tokens[i]; + if (t[0] === "A") { + const v = [t.slice(1), ...tokens.slice(i + 1, i + 6)].map(Number); + cmds.push(["A", v[0], v[1], v[2], v[3], v[4], v[5]]); + i += 6; + } else { + cmds.push([t[0] as "M" | "L", Number(t.slice(1)), Number(tokens[i + 1])]); + i += 2; + } + } + const out = { w: def[0], cmds }; + parsed.set(ch, out); + return out; +} + +/** Normalises arbitrary text to drawable glyphs; untrusted input never reaches a font engine. */ +export function drawableText(input: string, max = 120): string { + const cleaned = input + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[‘’]/g, "'") + .replace(/[“”]/g, '"') + .replace(/[–—]/g, "-") + .replace(/\s+/g, " ") + .trim(); + let out = ""; + for (const ch of cleaned) { + if (out.length >= max) break; + out += G[ch] ? ch : "?"; + } + return out.replace(/\?{2,}/g, "?"); +} +export function asciiShare(input: string) { + const chars = [...input.replace(/\s/g, "")]; + return chars.length ? chars.filter((c) => G[c] && c !== "?").length / chars.length : 1; +} + +export function parseFont(font: string) { + const size = Number(font.match(/(\d+(?:\.\d+)?)px/)?.[1] ?? 10); + const w = font.match(/\b([1-9]00)\b/)?.[1]; + const weight = w ? Number(w) : /\bbold\b/.test(font) ? 700 : 400; + const spacing = Number(font.match(/\bspacing=(-?\d+(?:\.\d+)?)/)?.[1] ?? 0); + return { size: Math.max(1, Math.min(400, size)), weight, spacing }; +} +const strokeUnits = (weight: number) => 0.9 + ((Math.max(100, Math.min(900, weight)) - 100) / 800) * 1.6; + +function layout(text: string, font: string) { + const { size, weight, spacing } = parseFont(font); + const unit = (size * 0.7) / 10, stroke = strokeUnits(weight); + const gap = 1.1 + stroke * 0.7 + spacing; + let x = 0; + const placed: { g: { w: number; cmds: Cmd[] }; x: number }[] = []; + for (const ch of text) { + const g = glyph(ch) ?? glyph("?")!; + placed.push({ g, x: x + stroke / 2 }); + x += g.w + gap + (ch === " " ? 0 : stroke); + } + return { placed, width: Math.max(0, x - gap) * unit, unit, stroke }; +} + +export const strokeFont: TextEngine = { + measure: (text, font) => layout(drawableText(text, 400), font).width, + draw(g: SoftCanvas, text: string, x: number, y: number, font: string, align: string, baseline: string, outline: boolean) { + const { placed, width, unit, stroke } = layout(drawableText(text, 400), font); + const ox = align === "center" ? x - width / 2 : align === "right" || align === "end" ? x - width : x; + const oy = baseline === "middle" ? y + 5 * unit : baseline === "top" || baseline === "hanging" ? y + 10.6 * unit : baseline === "bottom" ? y - 3 * unit : y; + if (!outline) g.strokeStyle = g.fillStyle; + g.lineWidth = outline ? g.lineWidth : stroke * unit; + g.lineCap = "round"; + g.lineJoin = "round"; + g.setLineDash([]); + for (const { g: gl, x: gx } of placed) { + g.beginPath(); + const px = (v: number) => ox + (gx + v) * unit, py = (v: number) => oy + v * unit; + for (const c of gl.cmds) { + if (c[0] === "M") g.moveTo(px(c[1]), py(c[2])); + else if (c[0] === "L") g.lineTo(px(c[1]), py(c[2])); + else { + const [, cx, cy, rx = 0, ry = 0, from = 0, to = 0] = c; + g.ellipse(px(cx), py(cy), rx * unit, ry * unit, 0, (from * Math.PI) / 180, (to * Math.PI) / 180, to < from); + } + } + g.stroke(); + } + }, +}; diff --git a/src/og/png.ts b/src/og/png.ts new file mode 100644 index 0000000..a2d4752 --- /dev/null +++ b/src/og/png.ts @@ -0,0 +1,68 @@ +import { deflateSync } from "node:zlib"; + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); +export function crc32(bytes: Uint8Array, crc = 0xffffffff) { + for (let i = 0; i < bytes.length; i++) crc = CRC_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} +function chunk(type: string, data: Uint8Array) { + const out = new Uint8Array(12 + data.length), view = new DataView(out.buffer); + view.setUint32(0, data.length); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + view.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length))); + return out; +} +const paeth = (a: number, b: number, c: number) => { + const p = a + b - c, pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); + return pa <= pb && pa <= pc ? a : pb <= pc ? b : c; +}; + +/** Encodes 8-bit RGB pixels as a PNG (colour type 2), choosing a filter per row. */ +export function encodePng(width: number, height: number, rgb: Uint8Array): Uint8Array { + if (rgb.length !== width * height * 3) throw new Error("PNG pixel buffer has the wrong size"); + const stride = width * 3, raw = new Uint8Array((stride + 1) * height), trial = new Uint8Array(stride); + for (let y = 0; y < height; y++) { + const row = rgb.subarray(y * stride, (y + 1) * stride), prev = y ? rgb.subarray((y - 1) * stride, y * stride) : null; + let best = 0, bestScore = Infinity; + for (let f = 0; f < 5; f++) { + if (!prev && (f === 2 || f === 4)) continue; + let score = 0; + for (let i = 0; i < stride; i++) { + const a = i >= 3 ? row[i - 3] : 0, b = prev ? prev[i] : 0, c = prev && i >= 3 ? prev[i - 3] : 0; + const v = (row[i] - (f === 0 ? 0 : f === 1 ? a : f === 2 ? b : f === 3 ? (a + b) >> 1 : paeth(a, b, c))) & 0xff; + score += v < 128 ? v : 256 - v; + } + if (score < bestScore) { bestScore = score; best = f; } + } + const o = y * (stride + 1); + raw[o] = best; + for (let i = 0; i < stride; i++) { + const a = i >= 3 ? row[i - 3] : 0, b = prev ? prev[i] : 0, c = prev && i >= 3 ? prev[i - 3] : 0; + trial[i] = (row[i] - (best === 0 ? 0 : best === 1 ? a : best === 2 ? b : best === 3 ? (a + b) >> 1 : paeth(a, b, c))) & 0xff; + } + raw.set(trial, o + 1); + } + const ihdr = new Uint8Array(13), view = new DataView(ihdr.buffer); + view.setUint32(0, width); + view.setUint32(4, height); + ihdr.set([8, 2, 0, 0, 0], 8); + const parts = [ + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk("IHDR", ihdr), + chunk("IDAT", new Uint8Array(deflateSync(raw, { level: 9 }))), + chunk("IEND", new Uint8Array(0)), + ]; + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} diff --git a/src/og/raster.ts b/src/og/raster.ts new file mode 100644 index 0000000..9cc374d --- /dev/null +++ b/src/og/raster.ts @@ -0,0 +1,567 @@ +/** A small, original, dependency-free software subset of CanvasRenderingContext2D. + * It exists so the server can draw the shared procedural world art (src/world) + * into a PNG for social previews without a native canvas. Unsupported features + * are ignored rather than emulated; every fill/stroke checks a CPU deadline. */ + +export class RenderBudgetError extends Error { + constructor() { + super("Preview render exceeded its CPU budget"); + } +} + +type Rgba = readonly [number, number, number, number]; // straight 0..255 rgb, alpha 0..1 +const TRANSPARENT: Rgba = [0, 0, 0, 0]; +const colorCache = new Map(); +const NAMED: Record = { + black: [0, 0, 0, 1], white: [255, 255, 255, 1], transparent: TRANSPARENT, red: [255, 0, 0, 1], + green: [0, 128, 0, 1], blue: [0, 0, 255, 1], gray: [128, 128, 128, 1], grey: [128, 128, 128, 1], +}; + +function hsl(h: number, s: number, l: number): [number, number, number] { + h = ((h % 360) + 360) % 360 / 360; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q; + const f = (t: number) => { + t = (t + 1) % 1; + return t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p; + }; + return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255]; +} + +/** Parses the CSS color forms used by the art; null means "invalid, keep previous". */ +export function parseColor(input: string): Rgba | null { + const key = input.trim().toLowerCase(); + const hit = colorCache.get(key); + if (hit !== undefined) return hit; + let out: Rgba | null = null; + if (key in NAMED) out = NAMED[key]; + else if (/^#[0-9a-f]+$/.test(key) && [4, 5, 7, 9].includes(key.length)) { + const short = key.length < 6; + const digits = short ? key.slice(1).split("").map((c) => c + c) : key.slice(1).match(/../g)!; + const v = digits.map((d) => parseInt(d, 16)); + out = [v[0], v[1], v[2], v.length > 3 ? v[3] / 255 : 1]; + } else { + const m = key.match(/^(rgba?|hsla?)\(([^)]*)\)$/); + if (m) { + const parts = m[2].split(/[\s,/]+/).filter(Boolean); + const num = (s: string, scale: number) => (s.endsWith("%") ? (parseFloat(s) / 100) * scale : parseFloat(s)); + if (parts.length >= 3) { + const a = parts.length > 3 ? Math.max(0, Math.min(1, num(parts[3], 1))) : 1; + if (m[1].startsWith("rgb")) out = [num(parts[0], 255), num(parts[1], 255), num(parts[2], 255), a]; + else { + const [r, g, b] = hsl(parseFloat(parts[0]), num(parts[1], 1), num(parts[2], 1)); + out = [r, g, b, a]; + } + if (out.some((v) => !Number.isFinite(v))) out = null; + } + } + } + if (colorCache.size > 4096) colorCache.clear(); + colorCache.set(key, out); + return out; +} + +type Stop = { offset: number; color: Rgba }; +/** Premultiplied 256-entry lookup, matching canvas premultiplied interpolation. */ +function gradientLut(stops: Stop[]) { + const lut = new Float32Array(256 * 4); + const sorted = stops.toSorted((a, b) => a.offset - b.offset); + for (let i = 0; i < 256; i++) { + const t = i / 255; + let k = 0; + while (k < sorted.length - 1 && sorted[k + 1].offset < t) k++; + const a = sorted[k], b = sorted[Math.min(sorted.length - 1, k + 1)]; + const span = b.offset - a.offset; + const u = t <= a.offset ? 0 : t >= b.offset ? 1 : span > 0 ? (t - a.offset) / span : 1; + const pa = a.color[3], pb = b.color[3]; + lut[i * 4] = a.color[0] * pa * (1 - u) + b.color[0] * pb * u; + lut[i * 4 + 1] = a.color[1] * pa * (1 - u) + b.color[1] * pb * u; + lut[i * 4 + 2] = a.color[2] * pa * (1 - u) + b.color[2] * pb * u; + lut[i * 4 + 3] = pa * (1 - u) + pb * u; + } + return lut; +} + +export class SoftGradient { + stops: Stop[] = []; + constructor( + readonly type: "linear" | "radial", + readonly coords: number[], + ) {} + addColorStop(offset: number, color: string) { + const c = parseColor(color); + if (!c || !Number.isFinite(offset)) return; + this.stops.push({ offset: Math.max(0, Math.min(1, offset)), color: c }); + } +} + +type Matrix = [number, number, number, number, number, number]; +type Point = [number, number]; +interface SubPath { points: Point[]; closed: boolean } +interface State { + m: Matrix; fill: Rgba | SoftGradient; stroke: Rgba | SoftGradient; alpha: number; lineWidth: number; + dash: number[]; dashOffset: number; cap: string; join: string; font: string; align: string; baseline: string; +} + +/** Glyph renderer injected by the font module so text uses the same rasterizer. */ +export interface TextEngine { + measure(text: string, font: string): number; + draw(g: SoftCanvas, text: string, x: number, y: number, font: string, align: string, baseline: string, stroke: boolean): void; +} + +const signedArea = (p: Point[]) => { + let s = 0; + for (let i = 0, j = p.length - 1; i < p.length; j = i++) s += (p[j][0] - p[i][0]) * (p[j][1] + p[i][1]); + return s; +}; +const oriented = (p: Point[]) => (signedArea(p) < 0 ? p.toReversed() : p); +function disc(cx: number, cy: number, r: number): Point[] { + const n = Math.max(8, Math.min(48, Math.ceil(r * 2.4))); + return oriented(Array.from({ length: n }, (_, i) => [cx + Math.cos((i / n) * Math.PI * 2) * r, cy + Math.sin((i / n) * Math.PI * 2) * r] as Point)); +} + +export class SoftCanvas { + readonly data: Float32Array; // straight-alpha RGBA, rgb 0..255, a 0..1 + private state: State; + private stack: State[] = []; + private path: SubPath[] = []; + private cover: Float32Array; + private ops = 0; + text: TextEngine | null = null; + imageSmoothingEnabled = true; + globalCompositeOperation = "source-over"; + shadowBlur = 0; + shadowColor = "transparent"; + constructor( + readonly width: number, + readonly height: number, + private readonly deadline = Number.POSITIVE_INFINITY, + private readonly clock: () => number = () => performance.now(), + ) { + this.data = new Float32Array(width * height * 4); + this.cover = new Float32Array(width + 2); + this.state = { + m: [1, 0, 0, 1, 0, 0], fill: [0, 0, 0, 1], stroke: [0, 0, 0, 1], alpha: 1, lineWidth: 1, + dash: [], dashOffset: 0, cap: "butt", join: "miter", font: "10px sans-serif", align: "start", baseline: "alphabetic", + }; + } + /** Coarse CPU guard; the clock is read every few operations. */ + private tick() { + if (++this.ops % 64 === 0 && this.clock() > this.deadline) throw new RenderBudgetError(); + } + + // --- state ------------------------------------------------------------- + save() { this.stack.push({ ...this.state, m: [...this.state.m] as Matrix, dash: [...this.state.dash] }); } + restore() { const s = this.stack.pop(); if (s) this.state = s; } + get fillStyle(): string | SoftGradient { return this.state.fill instanceof SoftGradient ? this.state.fill : `rgba(${this.state.fill.join(",")})`; } + set fillStyle(v: string | SoftGradient) { const c = typeof v === "string" ? parseColor(v) : v instanceof SoftGradient ? v : null; if (c) this.state.fill = c; } + get strokeStyle(): string | SoftGradient { return this.state.stroke instanceof SoftGradient ? this.state.stroke : `rgba(${this.state.stroke.join(",")})`; } + set strokeStyle(v: string | SoftGradient) { const c = typeof v === "string" ? parseColor(v) : v instanceof SoftGradient ? v : null; if (c) this.state.stroke = c; } + get globalAlpha() { return this.state.alpha; } + set globalAlpha(v: number) { if (Number.isFinite(v) && v >= 0 && v <= 1) this.state.alpha = v; } + get lineWidth() { return this.state.lineWidth; } + set lineWidth(v: number) { if (Number.isFinite(v) && v > 0) this.state.lineWidth = v; } + get lineCap() { return this.state.cap; } + set lineCap(v: string) { this.state.cap = v; } + get lineJoin() { return this.state.join; } + set lineJoin(v: string) { this.state.join = v; } + get lineDashOffset() { return this.state.dashOffset; } + set lineDashOffset(v: number) { if (Number.isFinite(v)) this.state.dashOffset = v; } + get font() { return this.state.font; } + set font(v: string) { this.state.font = v; } + get textAlign() { return this.state.align; } + set textAlign(v: string) { this.state.align = v; } + get textBaseline() { return this.state.baseline; } + set textBaseline(v: string) { this.state.baseline = v; } + setLineDash(d: number[]) { if (d.every((v) => Number.isFinite(v) && v >= 0)) this.state.dash = d.length % 2 ? [...d, ...d] : [...d]; } + getLineDash() { return [...this.state.dash]; } + + // --- transforms -------------------------------------------------------- + setTransform(a: number, b = 0, c = 0, d = 1, e = 0, f = 0) { this.state.m = [a, b, c, d, e, f]; } + resetTransform() { this.state.m = [1, 0, 0, 1, 0, 0]; } + transform(a: number, b: number, c: number, d: number, e: number, f: number) { + const [A, B, C, D, E, F] = this.state.m; + this.state.m = [A * a + C * b, B * a + D * b, A * c + C * d, B * c + D * d, A * e + C * f + E, B * e + D * f + F]; + } + translate(x: number, y: number) { this.transform(1, 0, 0, 1, x, y); } + scale(x: number, y: number) { this.transform(x, 0, 0, y, 0, 0); } + rotate(a: number) { const c = Math.cos(a), s = Math.sin(a); this.transform(c, s, -s, c, 0, 0); } + getTransform() { + const [a, b, c, d, e, f] = this.state.m; + return { a, b, c, d, e, f, transformPoint: (p: { x: number; y: number }) => ({ x: a * p.x + c * p.y + e, y: b * p.x + d * p.y + f }) }; + } + private tp(x: number, y: number): Point { + const [a, b, c, d, e, f] = this.state.m; + return [a * x + c * y + e, b * x + d * y + f]; + } + private get scaleFactor() { const [a, b, c, d] = this.state.m; return Math.sqrt(Math.abs(a * d - b * c)) || 1; } + + // --- paths --------------------------------------------------------------- + beginPath() { this.path = []; } + private current(): SubPath | undefined { return this.path[this.path.length - 1]; } + moveTo(x: number, y: number) { if (Number.isFinite(x) && Number.isFinite(y)) this.path.push({ points: [this.tp(x, y)], closed: false }); } + private lineToDevice(p: Point) { + const sp = this.current(); + if (!sp || sp.closed) this.path.push({ points: [p], closed: false }); + else sp.points.push(p); + } + lineTo(x: number, y: number) { if (Number.isFinite(x) && Number.isFinite(y)) this.lineToDevice(this.tp(x, y)); } + closePath() { + const sp = this.current(); + if (!sp) return; + sp.closed = true; + this.path.push({ points: [sp.points[0]], closed: false }); + } + private lastUser(): Point | null { + const sp = this.current(); + if (!sp) return null; + const [a, b, c, d, e, f] = this.state.m, det = a * d - b * c; + if (!det) return null; + const [X, Y] = sp.points[sp.points.length - 1]; + return [(d * (X - e) - c * (Y - f)) / det, (-b * (X - e) + a * (Y - f)) / det]; + } + private segments(len: number) { return Math.max(2, Math.min(64, Math.ceil((len * this.scaleFactor) / 3))); } + bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number) { + const p0 = this.lastUser() ?? [c1x, c1y]; + if (!this.current()) this.moveTo(p0[0], p0[1]); + const n = this.segments(Math.hypot(c1x - p0[0], c1y - p0[1]) + Math.hypot(c2x - c1x, c2y - c1y) + Math.hypot(x - c2x, y - c2y)); + for (let i = 1; i <= n; i++) { + const t = i / n, u = 1 - t; + this.lineTo(u * u * u * p0[0] + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x, u * u * u * p0[1] + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y); + } + } + quadraticCurveTo(cx: number, cy: number, x: number, y: number) { + const p0 = this.lastUser() ?? [cx, cy]; + if (!this.current()) this.moveTo(p0[0], p0[1]); + const n = this.segments(Math.hypot(cx - p0[0], cy - p0[1]) + Math.hypot(x - cx, y - cy)); + for (let i = 1; i <= n; i++) { + const t = i / n, u = 1 - t; + this.lineTo(u * u * p0[0] + 2 * u * t * cx + t * t * x, u * u * p0[1] + 2 * u * t * cy + t * t * y); + } + } + ellipse(x: number, y: number, rx: number, ry: number, rotation: number, start: number, end: number, ccw = false) { + if (![x, y, rx, ry, rotation, start, end].every(Number.isFinite) || rx < 0 || ry < 0) return; + let sweep = end - start; + const full = Math.PI * 2; + if (!ccw && sweep >= full) sweep = full; + else if (ccw && -sweep >= full) sweep = -full; + else if (!ccw) sweep = ((sweep % full) + full) % full; + else sweep = -(((-sweep % full) + full) % full); + const n = Math.max(4, Math.min(160, Math.ceil((Math.abs(sweep) / full) * Math.max(12, Math.max(rx, ry) * this.scaleFactor * 0.9)))); + const cr = Math.cos(rotation), sr = Math.sin(rotation); + for (let i = 0; i <= n; i++) { + const a = start + (sweep * i) / n, ex = Math.cos(a) * rx, ey = Math.sin(a) * ry; + const p = this.tp(x + ex * cr - ey * sr, y + ex * sr + ey * cr); + this.lineToDevice(p); + } + } + arc(x: number, y: number, r: number, start: number, end: number, ccw = false) { this.ellipse(x, y, r, r, 0, start, end, ccw); } + rect(x: number, y: number, w: number, h: number) { this.moveTo(x, y); this.lineTo(x + w, y); this.lineTo(x + w, y + h); this.lineTo(x, y + h); this.closePath(); } + roundRect(x: number, y: number, w: number, h: number, radii: number | number[] = 0) { + const r0 = Array.isArray(radii) ? radii[0] ?? 0 : radii; + const r = Math.max(0, Math.min(Math.abs(w) / 2, Math.abs(h) / 2, typeof r0 === "number" ? r0 : 0)); + if (!r) return this.rect(x, y, w, h); + this.moveTo(x + r, y); + this.lineTo(x + w - r, y); + this.ellipse(x + w - r, y + r, r, r, 0, -Math.PI / 2, 0); + this.lineTo(x + w, y + h - r); + this.ellipse(x + w - r, y + h - r, r, r, 0, 0, Math.PI / 2); + this.lineTo(x + r, y + h); + this.ellipse(x + r, y + h - r, r, r, 0, Math.PI / 2, Math.PI); + this.lineTo(x, y + r); + this.ellipse(x + r, y + r, r, r, 0, Math.PI, Math.PI * 1.5); + this.closePath(); + } + clip() {} + + // --- painting ------------------------------------------------------------ + fill(rule: "nonzero" | "evenodd" = "nonzero") { + const polys = this.path.filter((s) => s.points.length > 2).map((s) => s.points); + this.raster(polys, this.state.fill, rule === "evenodd"); + } + stroke() { + const polys: Point[][] = []; + const s = this.scaleFactor; + const width = this.state.lineWidth * s; + // Hairlines keep a minimum footprint and fade instead of disappearing. + const w = Math.max(0.6, width), fade = Math.min(1, width / 0.6); + for (const sp of this.path) { + const pts = sp.closed ? [...sp.points, sp.points[0]] : sp.points; + const pieces = this.state.dash.length && this.state.dash.some((d) => d > 0) ? dashed(pts, this.state.dash.map((d) => d * s), this.state.dashOffset * s) : [pts]; + for (const piece of pieces) strokePolyline(piece, w / 2, this.state.cap, this.state.join, sp.closed && pieces.length === 1, polys); + } + this.raster(polys, this.state.stroke, false, fade); + } + fillRect(x: number, y: number, w: number, h: number) { + if (![x, y, w, h].every(Number.isFinite)) return; + const [a, b, c, d, e, f] = this.state.m; + if (b === 0 && c === 0) { + let x0 = a * x + e, x1 = a * (x + w) + e, y0 = d * y + f, y1 = d * (y + h) + f; + if (x0 > x1) [x0, x1] = [x1, x0]; + if (y0 > y1) [y0, y1] = [y1, y0]; + return this.rectFast(x0, y0, x1, y1, this.state.fill); + } + const saved = this.path; + this.path = []; + this.rect(x, y, w, h); + this.fill(); + this.path = saved; + } + strokeRect(x: number, y: number, w: number, h: number) { + const saved = this.path; + this.path = []; + this.rect(x, y, w, h); + this.stroke(); + this.path = saved; + } + clearRect(x: number, y: number, w: number, h: number) { + const [a, , , d, e, f] = this.state.m; + const x0 = Math.max(0, Math.floor(a * x + e)), x1 = Math.min(this.width, Math.ceil(a * (x + w) + e)); + const y0 = Math.max(0, Math.floor(d * y + f)), y1 = Math.min(this.height, Math.ceil(d * (y + h) + f)); + for (let yy = y0; yy < y1; yy++) this.data.fill(0, (yy * this.width + x0) * 4, (yy * this.width + x1) * 4); + } + createLinearGradient(x0: number, y0: number, x1: number, y1: number) { return new SoftGradient("linear", [x0, y0, x1, y1]); } + createRadialGradient(x0: number, y0: number, r0: number, x1: number, y1: number, r1: number) { return new SoftGradient("radial", [x0, y0, r0, x1, y1, r1]); } + measureText(text: string) { return { width: this.text ? this.text.measure(String(text), this.state.font) : String(text).length * 6 }; } + fillText(text: string, x: number, y: number) { const p = this.text; if (p) { this.save(); p.draw(this, String(text), x, y, this.state.font, this.state.align, this.state.baseline, false); this.restore(); } } + strokeText(text: string, x: number, y: number) { const p = this.text; if (p) { this.save(); p.draw(this, String(text), x, y, this.state.font, this.state.align, this.state.baseline, true); this.restore(); } } + drawImage() {} + + /** Copies another canvas with nearest-neighbour scaling (retro low-resolution look). */ + blitNearest(src: SoftCanvas) { + for (let y = 0; y < this.height; y++) { + const sy = Math.min(src.height - 1, Math.floor((y * src.height) / this.height)); + for (let x = 0; x < this.width; x++) { + const sx = Math.min(src.width - 1, Math.floor((x * src.width) / this.width)); + const si = (sy * src.width + sx) * 4, di = (y * this.width + x) * 4; + this.data[di] = src.data[si]; this.data[di + 1] = src.data[si + 1]; this.data[di + 2] = src.data[si + 2]; this.data[di + 3] = src.data[si + 3]; + } + } + } + + // --- rasterization ------------------------------------------------------- + private paintAt(paint: Rgba | SoftGradient, cache: { lut?: Float32Array; m?: Matrix }) { + if (!(paint instanceof SoftGradient)) return null; + if (!cache.lut) { cache.lut = gradientLut(paint.stops.length ? paint.stops : [{ offset: 0, color: TRANSPARENT }]); cache.m = [...this.state.m] as Matrix; } + const lut = cache.lut, m = cache.m!; + const map = (x: number, y: number): Point => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]; + const s = Math.sqrt(Math.abs(m[0] * m[3] - m[1] * m[2])) || 1; + if (paint.type === "linear") { + const [ax, ay] = map(paint.coords[0], paint.coords[1]), [bx, by] = map(paint.coords[2], paint.coords[3]); + const dx = bx - ax, dy = by - ay, len2 = dx * dx + dy * dy; + return (px: number, py: number, out: Float32Array) => { + const t = len2 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0; + const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4; + out[0] = lut[i]; out[1] = lut[i + 1]; out[2] = lut[i + 2]; out[3] = lut[i + 3]; + }; + } + const [x0, y0] = map(paint.coords[0], paint.coords[1]), r0 = paint.coords[2] * s; + const [x1, y1] = map(paint.coords[3], paint.coords[4]), r1 = paint.coords[5] * s; + const cdx = x1 - x0, cdy = y1 - y0, dr = r1 - r0, a = cdx * cdx + cdy * cdy - dr * dr; + return (px: number, py: number, out: Float32Array) => { + const pdx = px - x0, pdy = py - y0; + const b = pdx * cdx + pdy * cdy + r0 * dr, c = pdx * pdx + pdy * pdy - r0 * r0; + let t: number; + if (Math.abs(a) < 1e-9) t = b ? c / (2 * b) : 0; + else { + const disc = b * b - a * c; + if (disc < 0) { out[0] = out[1] = out[2] = out[3] = 0; return; } + const sq = Math.sqrt(disc), t1 = (b + sq) / a, t2 = (b - sq) / a; + t = r0 + Math.max(t1, t2) * dr >= 0 ? Math.max(t1, t2) : Math.min(t1, t2); + if (r0 + t * dr < 0) { out[0] = out[1] = out[2] = out[3] = 0; return; } + } + const i = Math.max(0, Math.min(255, Math.round(t * 255))) * 4; + out[0] = lut[i]; out[1] = lut[i + 1]; out[2] = lut[i + 2]; out[3] = lut[i + 3]; + }; + } + private blend(i: number, r: number, g: number, b: number, a: number) { + // r,g,b premultiplied by a; destination straight alpha. + if (a <= 0) return; + const d = this.data, da = d[i + 3], keep = 1 - a, outA = a + da * keep; + if (outA <= 0) return; + d[i] = (r + d[i] * da * keep) / outA; + d[i + 1] = (g + d[i + 1] * da * keep) / outA; + d[i + 2] = (b + d[i + 2] * da * keep) / outA; + d[i + 3] = outA; + } + private rectFast(x0: number, y0: number, x1: number, y1: number, paint: Rgba | SoftGradient) { + this.tick(); + x0 = Math.max(0, x0); y0 = Math.max(0, y0); x1 = Math.min(this.width, x1); y1 = Math.min(this.height, y1); + if (x1 <= x0 || y1 <= y0) return; + const sample = this.paintAt(paint, {}), px = new Float32Array(4), alpha = this.state.alpha; + const solid = paint instanceof SoftGradient ? null : paint; + for (let y = Math.floor(y0); y < Math.ceil(y1); y++) { + const cy = Math.min(y + 1, y1) - Math.max(y, y0); + for (let x = Math.floor(x0); x < Math.ceil(x1); x++) { + const cov = cy * (Math.min(x + 1, x1) - Math.max(x, x0)) * alpha; + if (cov <= 0) continue; + if (solid) { const a = solid[3] * cov; this.blend((y * this.width + x) * 4, solid[0] * a, solid[1] * a, solid[2] * a, a); } + else { sample!(x + 0.5, y + 0.5, px); this.blend((y * this.width + x) * 4, px[0] * cov, px[1] * cov, px[2] * cov, px[3] * cov); } + } + } + } + /** Scanline coverage rasterizer: 4 vertical sub-samples, exact horizontal span ends. */ + private raster(polys: Point[][], paint: Rgba | SoftGradient, evenOdd: boolean, fade = 1) { + this.tick(); + const edges: number[] = []; // x0,y0,x1,y1,dir + let minY = Infinity, maxY = -Infinity, minX = Infinity, maxX = -Infinity; + for (const poly of polys) { + for (let i = 0; i < poly.length; i++) { + const [ax, ay] = poly[i], [bx, by] = poly[(i + 1) % poly.length]; + if (!Number.isFinite(ax + ay + bx + by)) return; + minX = Math.min(minX, ax); maxX = Math.max(maxX, ax); + if (ay === by) continue; + if (ay < by) edges.push(ax, ay, bx, by, 1); else edges.push(bx, by, ax, ay, -1); + minY = Math.min(minY, ay, by); maxY = Math.max(maxY, ay, by); + } + } + if (!edges.length) return; + const y0 = Math.max(0, Math.floor(minY)), y1 = Math.min(this.height, Math.ceil(maxY)); + const xLo = Math.max(0, Math.floor(minX)), xHi = Math.min(this.width, Math.ceil(maxX)); + if (y0 >= y1 || xLo >= xHi) return; + const count = edges.length / 5; + const order = Array.from({ length: count }, (_, i) => i).sort((p, q) => edges[p * 5 + 1] - edges[q * 5 + 1]); + const sample = this.paintAt(paint, {}), px = new Float32Array(4); + const solid = paint instanceof SoftGradient ? null : paint; + const alpha = this.state.alpha * fade, cover = this.cover, SS = 4; + let next = 0; + let active: number[] = []; + const xs: number[] = [], ws: number[] = []; + for (let y = y0; y < y1; y++) { + if ((y & 15) === 0) this.tick(); + let spanLo = Infinity, spanHi = -Infinity; + for (let s = 0; s < SS; s++) { + const sy = y + (s + 0.5) / SS; + while (next < count && edges[order[next] * 5 + 1] <= sy) active.push(order[next++]); + active = active.filter((e) => edges[e * 5 + 3] > sy); + xs.length = 0; ws.length = 0; + for (const e of active) { + const o = e * 5, ey0 = edges[o + 1]; + if (ey0 > sy) continue; + const t = (sy - ey0) / (edges[o + 3] - ey0); + xs.push(edges[o] + (edges[o + 2] - edges[o]) * t); + ws.push(edges[o + 4]); + } + if (xs.length < 2) continue; + const idx = xs.map((_, i) => i).sort((p, q) => xs[p] - xs[q]); + let wind = 0; + for (let k = 0; k < idx.length - 1; k++) { + wind += evenOdd ? 1 : ws[idx[k]]; + const inside = evenOdd ? wind % 2 !== 0 : wind !== 0; + if (!inside) continue; + const a = Math.max(xLo, xs[idx[k]]), b = Math.min(xHi, xs[idx[k + 1]]); + if (b <= a) continue; + const ia = Math.floor(a), ib = Math.floor(b); + spanLo = Math.min(spanLo, ia); spanHi = Math.max(spanHi, ib); + if (ia === ib) cover[ia] += (b - a) / SS; + else { + cover[ia] += (ia + 1 - a) / SS; + for (let x = ia + 1; x < ib; x++) cover[x] += 1 / SS; + if (ib < this.width) cover[ib] += (b - ib) / SS; + } + } + } + if (spanLo > spanHi) continue; + const hi = Math.min(this.width - 1, spanHi); + for (let x = spanLo; x <= hi; x++) { + const c = Math.min(1, cover[x]) * alpha; + cover[x] = 0; + if (c <= 0.002) continue; + const i = (y * this.width + x) * 4; + if (solid) { const a = solid[3] * c; this.blend(i, solid[0] * a, solid[1] * a, solid[2] * a, a); } + else { sample!(x + 0.5, y + 0.5, px); this.blend(i, px[0] * c, px[1] * c, px[2] * c, px[3] * c); } + } + if (spanHi + 1 < cover.length) cover[spanHi + 1] = 0; + } + } + + /** Opaque 8-bit RGB over a background colour. */ + toRgb(background: Rgba = [0, 0, 0, 1]): Uint8Array { + const out = new Uint8Array(this.width * this.height * 3); + for (let i = 0, j = 0; i < this.data.length; i += 4, j += 3) { + const a = this.data[i + 3]; + out[j] = Math.round(this.data[i] * a + background[0] * (1 - a)); + out[j + 1] = Math.round(this.data[i + 1] * a + background[1] * (1 - a)); + out[j + 2] = Math.round(this.data[i + 2] * a + background[2] * (1 - a)); + } + return out; + } +} + +function dashed(pts: Point[], dash: number[], offset: number): Point[][] { + const total = dash.reduce((a, b) => a + b, 0); + if (total <= 0.5) return [pts]; + const out: Point[][] = []; + let k = 0, left = dash[0], on = true; + let o = ((offset % total) + total) % total; + while (o > 0) { if (o >= left) { o -= left; k = (k + 1) % dash.length; left = dash[k]; on = !on; } else { left -= o; o = 0; } } + let cur: Point[] | null = on ? [pts[0]] : null; + let pieces = 0; + for (let i = 1; i < pts.length; i++) { + let [ax, ay] = pts[i - 1]; + const [bx, by] = pts[i]; + let seg = Math.hypot(bx - ax, by - ay); + while (seg > 0) { + if (++pieces > 20000) return out; + const step = Math.min(left, seg), t = step / seg; + const nx = ax + (bx - ax) * t, ny = ay + (by - ay) * t; + if (cur) cur.push([nx, ny]); + ax = nx; ay = ny; seg -= step; left -= step; + if (left <= 1e-9) { + if (cur && cur.length > 1) out.push(cur); + k = (k + 1) % dash.length; left = dash[k]; on = !on; + cur = on ? [[ax, ay]] : null; + } + } + } + if (cur && cur.length > 1) out.push(cur); + return out; +} + +/** Converts one polyline into consistently oriented polygons whose nonzero union is the stroke. */ +function strokePolyline(raw: Point[], hw: number, cap: string, join: string, closed: boolean, out: Point[][]) { + const pts: Point[] = []; + for (const p of raw) { const q = pts[pts.length - 1]; if (!q || Math.hypot(p[0] - q[0], p[1] - q[1]) > 1e-3) pts.push(p); } + if (pts.length < 2) { + if (pts.length === 1 && cap === "round") out.push(disc(pts[0][0], pts[0][1], hw)); + return; + } + const n = pts.length; + for (let i = 0; i < n - 1; i++) { + let [ax, ay] = pts[i], [bx, by] = pts[i + 1]; + const len = Math.hypot(bx - ax, by - ay), ux = (bx - ax) / len, uy = (by - ay) / len; + if (cap === "square" && !closed) { + if (i === 0) { ax -= ux * hw; ay -= uy * hw; } + if (i === n - 2) { bx += ux * hw; by += uy * hw; } + } + const nx = -uy * hw, ny = ux * hw; + out.push(oriented([[ax + nx, ay + ny], [bx + nx, by + ny], [bx - nx, by - ny], [ax - nx, ay - ny]])); + } + const joints = closed ? n : n - 1; + for (let i = 1; i < joints; i++) { + const p = pts[i % n], a = pts[i - 1], b = pts[(i + 1) % n]; + if (!a || !b) continue; + const l1 = Math.hypot(p[0] - a[0], p[1] - a[1]), l2 = Math.hypot(b[0] - p[0], b[1] - p[1]); + if (!l1 || !l2) continue; + const u1: Point = [(p[0] - a[0]) / l1, (p[1] - a[1]) / l1], u2: Point = [(b[0] - p[0]) / l2, (b[1] - p[1]) / l2]; + const cross = u1[0] * u2[1] - u1[1] * u2[0]; + if (Math.abs(cross) < 1e-4 && u1[0] * u2[0] + u1[1] * u2[1] > 0) continue; + if (join === "round" && hw > 0.9) { out.push(disc(p[0], p[1], hw)); continue; } + // Bevel on the outer side; miters extend it when the angle is gentle. + const side = cross > 0 ? -1 : 1; + const o1: Point = [p[0] - u1[1] * hw * side, p[1] + u1[0] * hw * side], o2: Point = [p[0] - u2[1] * hw * side, p[1] + u2[0] * hw * side]; + const tri: Point[] = [p, o1, o2]; + if (join === "miter") { + const mx = o1[0] + o2[0] - 2 * p[0], my = o1[1] + o2[1] - 2 * p[1], ml = Math.hypot(mx, my); + const cosHalf = Math.sqrt(Math.max(0, (1 + u1[0] * u2[0] + u1[1] * u2[1]) / 2)); + if (ml > 1e-6 && cosHalf > 0.1) { + const d = hw / cosHalf; + tri.splice(2, 0, [p[0] + (mx / ml) * d, p[1] + (my / ml) * d]); + } + } + out.push(oriented(tri)); + } + if (cap === "round" && !closed) { + out.push(disc(pts[0][0], pts[0][1], hw)); + out.push(disc(pts[n - 1][0], pts[n - 1][1], hw)); + } +} From c981927b7214eda722c9cfa3f743ae19d22a8ef3 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:26 -0800 Subject: [PATCH 02/17] feat(og): procedural 1200x630 world and homepage share cards World cards reuse the shared world art (layout, terrain, connections, buildings) for up to 30 districts in the requested style; the homepage card shows one world through all five perspectives with the tagline. Meta helpers build escaped, validated, absolute share metadata. Co-Authored-By: Claude Opus 5.5 --- src/og/card.ts | 269 +++++++++++++++++++++++++++++++++++++++++++++++++ src/og/meta.ts | 85 ++++++++++++++++ 2 files changed, 354 insertions(+) create mode 100644 src/og/card.ts create mode 100644 src/og/meta.ts diff --git a/src/og/card.ts b/src/og/card.ts new file mode 100644 index 0000000..e24df69 --- /dev/null +++ b/src/og/card.ts @@ -0,0 +1,269 @@ +import type { StyleId, WorldEdge, WorldNode } from "../contracts"; +import { illustration, STYLES } from "../client/demo"; +import { layoutWorld, mapBounds } from "../world/layout"; +import { PALETTES, paintBuilding, paintConnections, paintTerrain } from "../world/paint"; +import type { Pen } from "../world/primitives"; +import { drawableText, asciiShare, strokeFont } from "./font"; +import { STYLE_TITLES } from "./meta"; +import { encodePng } from "./png"; +import { SoftCanvas } from "./raster"; + +export const OG_WIDTH = 1200; +export const OG_HEIGHT = 630; +/** Districts drawn per card; the rest are summarised by the stats line. */ +export const OG_MAX_NODES = 30; +export const OG_MAX_EDGES = 96; +export const TAGLINE = "Every codebase. A world of its own."; + +export interface CardWorld { + fullName: string; + nodes: WorldNode[]; + edges: WorldEdge[]; + totalNodes: number; + totalEdges: number; + stars: number | null; + language: string | null; + description: string | null; +} +export interface CardOptions { + /** Absolute monotonic deadline (performance.now()). */ + deadline?: number; + /** Display-only host/path line, e.g. "chaorders.com/github-factory". */ + site?: string | null; +} + +const BRAND_INK = "#f5f3ea"; +const pen = (c: SoftCanvas) => c as unknown as Pen; +function canvas(width: number, height: number, deadline: number) { + const c = new SoftCanvas(width, height, deadline); + c.text = strokeFont; + return c; +} + +/** Draws a world into the target rectangle using the live canvas's shared art. */ +function paintWorld(target: SoftCanvas, world: Pick, style: StyleId, view: { x: number; y: number; w: number; h: number; zoom: number }, deadline: number) { + const nodes = world.nodes.slice(0, OG_MAX_NODES); + const ids = new Set(nodes.map((n) => n.id)); + const edges = world.edges.filter((e) => ids.has(e.source) && ids.has(e.target)).slice(0, OG_MAX_EDGES); + const placed = layoutWorld(nodes, style); + const b = mapBounds(placed); + // Red Alert keeps its deliberately coarse raster, like the live world. + const res = style === "red-alert" ? 0.62 : 1; + const scene = res === 1 ? target : canvas(Math.ceil(target.width * res), Math.ceil(target.height * res), deadline); + // Fit the district bounds into the requested focus rectangle; terrain fills the rest. + const k = Math.min(1.6, Math.min(view.w / b.width, view.h / b.height) * view.zoom); + const ox = view.x + view.w / 2 - (b.x + b.width / 2) * k, oy = view.y + view.h / 2 - (b.y + b.height / 2) * k; + const g = pen(scene); + g.setTransform(res, 0, 0, res, 0, 0); + g.fillStyle = PALETTES[style].ground; + g.fillRect(0, 0, target.width, target.height); + g.setTransform(res * k, 0, 0, res * k, res * ox, res * oy); + paintTerrain(g, style, placed, world.fullName); + paintConnections(g, placed, edges, style, 0, false); + for (const pn of placed.toSorted((a, c) => a.y - c.y)) paintBuilding(g, pn, style, false, false, 1); + g.resetTransform(); + if (scene !== target) target.blitNearest(scene); +} + +function scrims(g: Pen, style: StyleId, strength = 1) { + const left = g.createLinearGradient(0, 0, OG_WIDTH * 0.72, 0); + left.addColorStop(0, `rgba(8,10,12,${0.94 * strength})`); + left.addColorStop(0.45, `rgba(8,10,12,${0.78 * strength})`); + left.addColorStop(1, "rgba(8,10,12,0)"); + g.fillStyle = left; + g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT); + const bottom = g.createLinearGradient(0, OG_HEIGHT * 0.55, 0, OG_HEIGHT); + bottom.addColorStop(0, "rgba(8,10,12,0)"); + bottom.addColorStop(1, `rgba(8,10,12,${0.72 * strength})`); + g.fillStyle = bottom; + g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT); + const vignette = g.createRadialGradient(OG_WIDTH * 0.62, OG_HEIGHT * 0.5, OG_HEIGHT * 0.3, OG_WIDTH * 0.62, OG_HEIGHT * 0.5, OG_WIDTH * 0.75); + vignette.addColorStop(0, "rgba(0,0,0,0)"); + vignette.addColorStop(1, style === "civilization" ? "rgba(48,40,22,0.35)" : "rgba(4,8,12,0.5)"); + g.fillStyle = vignette; + g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT); +} + +/** Original crescent mark plus wordmark. The crescent is one polygon, so it + * reads correctly on light and dark ground alike. */ +function crescent(g: Pen, cx: number, cy: number, r: number) { + const ix = cx + r * 0.42, iy = cy - r * 0.34, ir = r * 0.84; + const outer: number[][] = [], inner: number[][] = []; + for (let i = 0; i <= 96; i++) { + const a = (i / 96) * Math.PI * 2, dx = Math.cos(a), dy = Math.sin(a); + // Ray from the outer centre leaves the inner disc at t; keep [t, r]. + const ox = cx - ix, oy = cy - iy, b = ox * dx + oy * dy, c = ox * ox + oy * oy - ir * ir; + const t = -b + Math.sqrt(Math.max(0, b * b - c)); + if (t >= r) continue; + outer.push([cx + dx * r, cy + dy * r]); + inner.push([cx + dx * t, cy + dy * t]); + } + g.beginPath(); + [...outer, ...inner.reverse()].forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y))); + g.closePath(); + g.fill(); +} +function brand(g: Pen, x: number, y: number, accent: string, size = 1) { + g.save(); + g.fillStyle = accent; + crescent(g, x + 15 * size, y, 15 * size); + g.fillStyle = BRAND_INK; + g.font = `700 ${Math.round(21 * size)}px spacing=1.6`; + g.textBaseline = "middle"; + g.fillText("MOON FACTORY", x + 40 * size, y); + g.restore(); +} + +function fitFont(g: Pen, text: string, weight: number, max: number, min: number, width: number) { + for (let size = max; size > min; size -= 2) { + g.font = `${weight} ${size}px`; + if (g.measureText(text).width <= width) return size; + } + g.font = `${weight} ${min}px`; + return min; +} +function ellipsize(g: Pen, text: string, width: number) { + if (g.measureText(text).width <= width) return text; + let t = text; + while (t.length > 1 && g.measureText(t + "…").width > width) t = t.slice(0, -1); + return t.trimEnd() + "…"; +} +const compact = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(n >= 1e7 ? 0 : 1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n >= 1e4 ? 0 : 1)}k` : String(n)).replace(".0", ""); + +function chip(g: Pen, x: number, y: number, text: string, accent: string) { + g.font = "600 21px"; + const w = g.measureText(text).width + 30; + g.fillStyle = "rgba(10,12,14,0.72)"; + g.beginPath(); + g.roundRect(x, y, w, 42, 21); + g.fill(); + g.strokeStyle = `${accent}99`; + g.lineWidth = 1.5; + g.stroke(); + g.fillStyle = BRAND_INK; + g.textBaseline = "middle"; + g.textAlign = "left"; + g.fillText(text, x + 15, y + 21); + return w; +} + +function footer(g: Pen, site: string | null | undefined, accent: string) { + g.fillStyle = accent; + g.fillRect(0, OG_HEIGHT - 6, OG_WIDTH, 6); + if (!site) return; + g.font = "500 19px spacing=0.6"; + g.textBaseline = "alphabetic"; + g.textAlign = "right"; + g.fillStyle = "rgba(245,243,234,0.82)"; + g.fillText(drawableText(site, 60), OG_WIDTH - 52, OG_HEIGHT - 34); + g.textAlign = "left"; +} + +function finish(c: SoftCanvas) { + return encodePng(c.width, c.height, c.toRgb([11, 13, 16, 1])); +} + +export function renderWorldCard(world: CardWorld, style: StyleId, options: CardOptions = {}): Uint8Array { + const deadline = options.deadline ?? performance.now() + 2500; + const c = canvas(OG_WIDTH, OG_HEIGHT, deadline), g = pen(c); + const p = PALETTES[style]; + paintWorld(c, world, style, { x: 520, y: 40, w: 660, h: 560, zoom: 1.08 }, deadline); + scrims(g, style); + brand(g, 64, 74, p.trim); + // Eyebrow: perspective name in the style's accent. + g.font = "700 20px spacing=2.4"; + g.fillStyle = p.trim; + g.textBaseline = "alphabetic"; + g.fillText(`${STYLE_TITLES[style].toUpperCase()} WORLD`, 66, 214); + const [owner, repo] = world.fullName.split("/"); + const safeOwner = drawableText(owner ?? "", 60), safeRepo = drawableText(repo ?? "", 100); + g.font = "500 34px"; + g.fillStyle = "rgba(245,243,234,0.72)"; + g.fillText(ellipsize(g, `${safeOwner} /`, 640), 64, 268); + const size = fitFont(g, safeRepo, 700, 92, 46, 660); + g.fillStyle = BRAND_INK; + g.fillText(ellipsize(g, safeRepo, 660), 62, 268 + size * 1.02); + let y = 268 + size * 1.02 + 30; + const description = world.description && asciiShare(world.description) > 0.85 ? drawableText(world.description, 160) : ""; + if (description) { + g.font = "450 22px"; + g.fillStyle = "rgba(245,243,234,0.78)"; + g.fillText(ellipsize(g, description, 600), 64, y + 24); + y += 44; + } + let x = 64; + const chips = [ + world.stars !== null ? `★ ${compact(world.stars)}` : null, + `${compact(world.totalNodes)} district${world.totalNodes === 1 ? "" : "s"}`, + `${compact(world.totalEdges)} link${world.totalEdges === 1 ? "" : "s"}`, + world.language ? drawableText(world.language, 18) : null, + ].filter((v): v is string => Boolean(v)); + for (const text of chips) x += chip(g, x, Math.min(y + 18, OG_HEIGHT - 120), text, p.trim) + 12; + footer(g, options.site, p.trim); + g.font = "500 19px"; + g.fillStyle = "rgba(245,243,234,0.62)"; + g.fillText(TAGLINE, 64, OG_HEIGHT - 34); + return finish(c); +} + +/** Generic card: the illustrative world seen through all five perspectives. */ +export function renderHomeCard(options: CardOptions = {}): Uint8Array { + const deadline = options.deadline ?? performance.now() + 8000; + const c = canvas(OG_WIDTH, OG_HEIGHT, deadline), g = pen(c); + const demo = illustration(); + const world = { fullName: "moon-factory/illustration", nodes: demo.nodes, edges: demo.edges }; + const order: StyleId[] = ["moonshot", "factorio", "red-alert", "red-alert-2", "civilization"]; + // Five slanted bands on the right, each a different perspective of one world. + const x0 = 470, band = (OG_WIDTH - x0) / order.length + 26, slant = 70; + order.forEach((style, i) => { + const slice = canvas(OG_WIDTH, OG_HEIGHT, deadline); + paintWorld(slice, world, style, { x: 520, y: 20, w: 680, h: 610, zoom: 1.15 }, deadline); + const left = x0 + i * (band - 26) - (i ? 0 : 400), right = left + band + (i ? 0 : 400); + // Copy a parallelogram from the slice into the card. + for (let y = 0; y < OG_HEIGHT; y++) { + const shift = (1 - y / OG_HEIGHT) * slant; + const a = Math.max(0, Math.floor(left + shift)), b = Math.min(OG_WIDTH, Math.floor(right + shift)); + c.data.set(slice.data.subarray((y * OG_WIDTH + a) * 4, (y * OG_WIDTH + b) * 4), (y * OG_WIDTH + a) * 4); + } + if (i) { + g.strokeStyle = "rgba(11,13,16,0.9)"; + g.lineWidth = 6; + g.beginPath(); + g.moveTo(left + slant, 0); + g.lineTo(left, OG_HEIGHT); + g.stroke(); + } + }); + const left = g.createLinearGradient(0, 0, 900, 0); + left.addColorStop(0, "rgba(9,11,14,0.97)"); + left.addColorStop(0.5, "rgba(9,11,14,0.88)"); + left.addColorStop(1, "rgba(9,11,14,0)"); + g.fillStyle = left; + g.fillRect(0, 0, OG_WIDTH, OG_HEIGHT); + brand(g, 64, 78, "#efb17c"); + g.fillStyle = BRAND_INK; + g.font = "700 76px"; + g.textBaseline = "alphabetic"; + g.fillText("Every codebase.", 60, 268); + g.fillStyle = "#efb17c"; + g.fillText("A world of its own.", 60, 360); + g.font = "450 25px"; + g.fillStyle = "rgba(245,243,234,0.8)"; + g.fillText("Turn any public GitHub repository into", 64, 432); + g.fillText("a living world. Five perspectives, one source.", 64, 468); + let x = 64; + for (const s of order) { + const info = STYLES.find((v) => v.id === s)!; + g.fillStyle = PALETTES[s].trim; + g.beginPath(); + g.arc(x + 7, 531, 7, 0, Math.PI * 2); + g.fill(); + x += 26; + } + g.font = "600 19px spacing=1.2"; + g.fillStyle = "rgba(245,243,234,0.7)"; + g.textBaseline = "middle"; + g.fillText("5 ORIGINAL ART STYLES", x + 8, 531); + footer(g, options.site, "#efb17c"); + return finish(c); +} diff --git a/src/og/meta.ts b/src/og/meta.ts new file mode 100644 index 0000000..8daeb56 --- /dev/null +++ b/src/og/meta.ts @@ -0,0 +1,85 @@ +import { STYLE_IDS, type StyleId } from "../contracts"; +import { appPath } from "../base-path"; + +/** Client-safe social metadata. Every value is derived from validated route + * params and fixed copy; no repository-supplied text reaches a meta tag. */ +export const SITE_NAME = "Moon Factory"; +export const HOME_TITLE = "Moon Factory — Your codebase, a living world"; +export const HOME_DESCRIPTION = "Every codebase. A world of its own. Turn a public GitHub repository into a living, explorable world. Five perspectives. One source of truth."; +export const THEME_COLOR = "#0b0d10"; +export const OG_IMAGE_ALT = "A procedurally drawn Moon Factory world diorama."; + +/** Social copy names for each perspective. */ +export const STYLE_TITLES: Record = { + moonshot: "Moonshot", + factorio: "Factorio", + "red-alert": "Red Alert", + "red-alert-2": "Red Alert 2", + civilization: "Civilization", +}; +export const isStyleId = (value: unknown): value is StyleId => typeof value === "string" && (STYLE_IDS as readonly string[]).includes(value); + +/** Same owner/repository rules as server/github.ts parseRepo (verified by tests). */ +export function validRepoParams(owner: unknown, repo: unknown): { owner: string; repo: string } | null { + if (typeof owner !== "string" || typeof repo !== "string") return null; + const name = repo.replace(/\.git$/, ""); + if (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(owner) || !/^[a-zA-Z0-9_.-]{1,100}$/.test(name) || name === "." || name === "..") return null; + return { owner, repo: name }; +} + +/** Accepts only a bare http(s) origin; anything else is rejected. */ +export function normalizeOrigin(value: unknown): string | null { + if (typeof value !== "string" || !value) return null; + try { + const url = new URL(value); + if ((url.protocol !== "https:" && url.protocol !== "http:") || url.origin !== value.replace(/\/$/, "") || url.username || url.password) return null; + return url.origin; + } catch { + return null; + } +} +export const absoluteUrl = (origin: string, path: string) => `${origin}${appPath(path)}`; + +type Meta = { name?: string; property?: string; content: string } | { title: string }; +function social(origin: string, title: string, description: string, path: string, image: string, alt: string): Meta[] { + const url = absoluteUrl(origin, path), imageUrl = absoluteUrl(origin, image); + return [ + { title }, + { name: "description", content: description }, + { property: "og:site_name", content: SITE_NAME }, + { property: "og:type", content: "website" }, + { property: "og:locale", content: "en_US" }, + { property: "og:title", content: title }, + { property: "og:description", content: description }, + { property: "og:url", content: url }, + { property: "og:image", content: imageUrl }, + { property: "og:image:type", content: "image/png" }, + { property: "og:image:width", content: "1200" }, + { property: "og:image:height", content: "630" }, + { property: "og:image:alt", content: alt }, + { name: "twitter:card", content: "summary_large_image" }, + { name: "twitter:title", content: title }, + { name: "twitter:description", content: description }, + { name: "twitter:image", content: imageUrl }, + { name: "twitter:image:alt", content: alt }, + { name: "theme-color", content: THEME_COLOR }, + ]; +} + +export const HOME_IMAGE_PATH = "og/home.png"; +export const homeMeta = (origin: string) => social(origin, HOME_TITLE, HOME_DESCRIPTION, "", HOME_IMAGE_PATH, OG_IMAGE_ALT); + +export function worldImagePath(owner: string, repo: string, style: StyleId) { + return `og/world/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}?style=${style}`; +} +/** Invalid params fall back to the generic homepage card, never echoed text. */ +export function worldMeta(origin: string, owner: unknown, repo: unknown, styleParam: unknown) { + const valid = validRepoParams(owner, repo); + if (!valid) return homeMeta(origin); + const style: StyleId = isStyleId(styleParam) ? styleParam : "moonshot"; + const full = `${valid.owner}/${valid.repo}`, perspective = STYLE_TITLES[style]; + const title = `${full} as a ${perspective} world · ${SITE_NAME}`; + const description = `Explore the public GitHub repository ${full} as a living ${perspective} world: districts, connections and recent activity, every one linked back to source.`; + const page = `world/${encodeURIComponent(valid.owner)}/${encodeURIComponent(valid.repo)}${isStyleId(styleParam) ? `?style=${style}` : ""}`; + return social(origin, title, description, page, worldImagePath(valid.owner, valid.repo, style), `${full} drawn as a procedural ${perspective} world.`); +} From 585124616e88412ae55985077f7d5936867918b0 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:26 -0800 Subject: [PATCH 03/17] feat(og): cache-only share image routes /og/home.png and /og/world/:owner/:repo?style= read only the existing world cache. Misses, unverified sources and render-budget failures serve the generic card with a short cache; hits are LRU-cached per repo+style+head with CDN cache headers and ETags. Origin comes from MOONSHOT_PUBLIC_ORIGIN only. Co-Authored-By: Claude Opus 5.5 --- src/routeTree.gen.ts | 42 +++++++++ src/routes/og.home[.]png.ts | 5 ++ src/routes/og.world.$owner.$repo.ts | 6 ++ src/server/og-image.ts | 133 ++++++++++++++++++++++++++++ src/server/og-routes.ts | 30 +++++++ src/server/public-origin.ts | 19 ++++ 6 files changed, 235 insertions(+) create mode 100644 src/routes/og.home[.]png.ts create mode 100644 src/routes/og.world.$owner.$repo.ts create mode 100644 src/server/og-image.ts create mode 100644 src/server/og-routes.ts create mode 100644 src/server/public-origin.ts diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3bb10b0..48de398 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,11 +11,13 @@ import { Route as ApiBudgetRouteImport } from './routes/api.budget' import { Route as ApiGalleryRouteImport } from './routes/api.gallery' import { Route as ApiHealthRouteImport } from './routes/api.health' import { Route as ApiWorldsRouteImport } from './routes/api.worlds' +import { Route as OgHomeDotpngRouteImport } from './routes/og.home[.]png' import { Route as WorldOwnerRepoRouteImport } from './routes/world.$owner.$repo' import { Route as ApiBlueprintOwnerRepoRouteImport } from './routes/api.blueprint.$owner.$repo' import { Route as ApiGalleryPreviewOwnerRepoRouteImport } from './routes/api.gallery-preview.$owner.$repo' import { Route as ApiHistoryOwnerRepoRouteImport } from './routes/api.history.$owner.$repo' import { Route as ApiWorldOwnerRepoRouteImport } from './routes/api.world.$owner.$repo' +import { Route as OgWorldOwnerRepoRouteImport } from './routes/og.world.$owner.$repo' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -47,6 +49,11 @@ const ApiWorldsRoute = ApiWorldsRouteImport.update({ path: '/api/worlds', getParentRoute: () => rootRouteImport, } as any) +const OgHomeDotpngRoute = OgHomeDotpngRouteImport.update({ + id: '/og/home.png', + path: '/og/home.png', + getParentRoute: () => rootRouteImport, +} as any) const WorldOwnerRepoRoute = WorldOwnerRepoRouteImport.update({ id: '/world/$owner/$repo', path: '/world/$owner/$repo', @@ -73,6 +80,11 @@ const ApiWorldOwnerRepoRoute = ApiWorldOwnerRepoRouteImport.update({ path: '/api/world/$owner/$repo', getParentRoute: () => rootRouteImport, } as any) +const OgWorldOwnerRepoRoute = OgWorldOwnerRepoRouteImport.update({ + id: '/og/world/$owner/$repo', + path: '/og/world/$owner/$repo', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -81,11 +93,13 @@ export interface FileRoutesByFullPath { '/api/gallery': typeof ApiGalleryRoute '/api/health': typeof ApiHealthRoute '/api/worlds': typeof ApiWorldsRoute + '/og/home.png': typeof OgHomeDotpngRoute '/world/$owner/$repo': typeof WorldOwnerRepoRoute '/api/blueprint/$owner/$repo': typeof ApiBlueprintOwnerRepoRoute '/api/gallery-preview/$owner/$repo': typeof ApiGalleryPreviewOwnerRepoRoute '/api/history/$owner/$repo': typeof ApiHistoryOwnerRepoRoute '/api/world/$owner/$repo': typeof ApiWorldOwnerRepoRoute + '/og/world/$owner/$repo': typeof OgWorldOwnerRepoRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -94,11 +108,13 @@ export interface FileRoutesByTo { '/api/gallery': typeof ApiGalleryRoute '/api/health': typeof ApiHealthRoute '/api/worlds': typeof ApiWorldsRoute + '/og/home.png': typeof OgHomeDotpngRoute '/world/$owner/$repo': typeof WorldOwnerRepoRoute '/api/blueprint/$owner/$repo': typeof ApiBlueprintOwnerRepoRoute '/api/gallery-preview/$owner/$repo': typeof ApiGalleryPreviewOwnerRepoRoute '/api/history/$owner/$repo': typeof ApiHistoryOwnerRepoRoute '/api/world/$owner/$repo': typeof ApiWorldOwnerRepoRoute + '/og/world/$owner/$repo': typeof OgWorldOwnerRepoRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -108,11 +124,13 @@ export interface FileRoutesById { '/api/gallery': typeof ApiGalleryRoute '/api/health': typeof ApiHealthRoute '/api/worlds': typeof ApiWorldsRoute + '/og/home.png': typeof OgHomeDotpngRoute '/world/$owner/$repo': typeof WorldOwnerRepoRoute '/api/blueprint/$owner/$repo': typeof ApiBlueprintOwnerRepoRoute '/api/gallery-preview/$owner/$repo': typeof ApiGalleryPreviewOwnerRepoRoute '/api/history/$owner/$repo': typeof ApiHistoryOwnerRepoRoute '/api/world/$owner/$repo': typeof ApiWorldOwnerRepoRoute + '/og/world/$owner/$repo': typeof OgWorldOwnerRepoRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -123,11 +141,13 @@ export interface FileRouteTypes { | '/api/gallery' | '/api/health' | '/api/worlds' + | '/og/home.png' | '/world/$owner/$repo' | '/api/blueprint/$owner/$repo' | '/api/gallery-preview/$owner/$repo' | '/api/history/$owner/$repo' | '/api/world/$owner/$repo' + | '/og/world/$owner/$repo' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -136,11 +156,13 @@ export interface FileRouteTypes { | '/api/gallery' | '/api/health' | '/api/worlds' + | '/og/home.png' | '/world/$owner/$repo' | '/api/blueprint/$owner/$repo' | '/api/gallery-preview/$owner/$repo' | '/api/history/$owner/$repo' | '/api/world/$owner/$repo' + | '/og/world/$owner/$repo' id: | '__root__' | '/' @@ -149,11 +171,13 @@ export interface FileRouteTypes { | '/api/gallery' | '/api/health' | '/api/worlds' + | '/og/home.png' | '/world/$owner/$repo' | '/api/blueprint/$owner/$repo' | '/api/gallery-preview/$owner/$repo' | '/api/history/$owner/$repo' | '/api/world/$owner/$repo' + | '/og/world/$owner/$repo' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -163,11 +187,13 @@ export interface RootRouteChildren { ApiGalleryRoute: typeof ApiGalleryRoute ApiHealthRoute: typeof ApiHealthRoute ApiWorldsRoute: typeof ApiWorldsRoute + OgHomeDotpngRoute: typeof OgHomeDotpngRoute WorldOwnerRepoRoute: typeof WorldOwnerRepoRoute ApiBlueprintOwnerRepoRoute: typeof ApiBlueprintOwnerRepoRoute ApiGalleryPreviewOwnerRepoRoute: typeof ApiGalleryPreviewOwnerRepoRoute ApiHistoryOwnerRepoRoute: typeof ApiHistoryOwnerRepoRoute ApiWorldOwnerRepoRoute: typeof ApiWorldOwnerRepoRoute + OgWorldOwnerRepoRoute: typeof OgWorldOwnerRepoRoute } declare module '@tanstack/react-router' { @@ -214,6 +240,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiWorldsRouteImport parentRoute: typeof rootRouteImport } + '/og/home.png': { + id: '/og/home.png' + path: '/og/home.png' + fullPath: '/og/home.png' + preLoaderRoute: typeof OgHomeDotpngRouteImport + parentRoute: typeof rootRouteImport + } '/world/$owner/$repo': { id: '/world/$owner/$repo' path: '/world/$owner/$repo' @@ -249,6 +282,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiWorldOwnerRepoRouteImport parentRoute: typeof rootRouteImport } + '/og/world/$owner/$repo': { + id: '/og/world/$owner/$repo' + path: '/og/world/$owner/$repo' + fullPath: '/og/world/$owner/$repo' + preLoaderRoute: typeof OgWorldOwnerRepoRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -259,11 +299,13 @@ const rootRouteChildren: RootRouteChildren = { ApiGalleryRoute: ApiGalleryRoute, ApiHealthRoute: ApiHealthRoute, ApiWorldsRoute: ApiWorldsRoute, + OgHomeDotpngRoute: OgHomeDotpngRoute, WorldOwnerRepoRoute: WorldOwnerRepoRoute, ApiBlueprintOwnerRepoRoute: ApiBlueprintOwnerRepoRoute, ApiGalleryPreviewOwnerRepoRoute: ApiGalleryPreviewOwnerRepoRoute, ApiHistoryOwnerRepoRoute: ApiHistoryOwnerRepoRoute, ApiWorldOwnerRepoRoute: ApiWorldOwnerRepoRoute, + OgWorldOwnerRepoRoute: OgWorldOwnerRepoRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/og.home[.]png.ts b/src/routes/og.home[.]png.ts new file mode 100644 index 0000000..f04742b --- /dev/null +++ b/src/routes/og.home[.]png.ts @@ -0,0 +1,5 @@ +import { createFileRoute } from "@tanstack/react-router"; +const serve = async ({ request }: { request: Request }) => (await import("../server/og-routes")).serveHomeImage(request); +export const Route = createFileRoute("/og/home.png")({ + server: { handlers: { GET: serve, HEAD: serve } }, +}); diff --git a/src/routes/og.world.$owner.$repo.ts b/src/routes/og.world.$owner.$repo.ts new file mode 100644 index 0000000..1255672 --- /dev/null +++ b/src/routes/og.world.$owner.$repo.ts @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +const serve = async ({ request, params }: { request: Request; params: { owner: string; repo: string } }) => + (await import("../server/og-routes")).serveWorldImage(request, params.owner, params.repo); +export const Route = createFileRoute("/og/world/$owner/$repo")({ + server: { handlers: { GET: serve, HEAD: serve } }, +}); diff --git a/src/server/og-image.ts b/src/server/og-image.ts new file mode 100644 index 0000000..4ef20cc --- /dev/null +++ b/src/server/og-image.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import type { StyleId, WorldEdge, WorldNode, WorldSnapshot } from "../contracts"; +import { renderHomeCard, renderWorldCard, type CardOptions, type CardWorld } from "../og/card"; +import { isStyleId, normalizeOrigin } from "../og/meta"; +import { projectRepositoryPreview } from "./gallery-preview"; +import { parseRepo } from "./github"; + +/** A world card is immutable for one capture (repo + style + head), so CDNs may keep it. */ +export const OG_CACHE_WORLD = "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800"; +/** The generic card stands in for a miss only briefly, so a later capture appears. */ +export const OG_CACHE_FALLBACK = "public, max-age=300, s-maxage=600"; +export const OG_CACHE_HOME = "public, max-age=86400, s-maxage=604800, stale-while-revalidate=604800"; + +export type OgImage = { body: Uint8Array; etag: string; cacheControl: string; kind: "world" | "home" }; +type Renderers = { + world: (world: CardWorld, style: StyleId, options: CardOptions) => Uint8Array; + home: (options: CardOptions) => Uint8Array; +}; +export interface OgImageOptions { + /** Read-only cache lookup. The service never collects, analyses or writes. */ + getCached: (input: string) => Promise; + site?: string | null; + clock?: () => number; + maxEntries?: number; + maxBytes?: number; + rendersPerMinute?: number; + renderMs?: number; + render?: Renderers; +} + +const etagOf = (body: Uint8Array) => `"og-${createHash("sha256").update(body).digest("hex").slice(0, 32)}"`; + +/** Cache-only social cards with a bounded LRU, a global render rate and a CPU deadline per render. */ +export function createOgImageService(options: OgImageOptions) { + const clock = options.clock ?? (() => performance.now()); + const render = options.render ?? { world: renderWorldCard, home: renderHomeCard }; + const maxEntries = options.maxEntries ?? 48, maxBytes = options.maxBytes ?? 24 * 1024 * 1024; + const rendersPerMinute = options.rendersPerMinute ?? 20, renderMs = options.renderMs ?? 1500; + const site = options.site ?? null; + const lru = new Map(); + let bytes = 0, homeImage: OgImage | undefined, homeFallback: OgImage | undefined; + const renders: number[] = []; + + const home = (): OgImage => { + if (!homeImage) { + const body = render.home({ site, deadline: clock() + 8000 }); + homeImage = { body, etag: etagOf(body), cacheControl: OG_CACHE_HOME, kind: "home" }; + homeFallback = { ...homeImage, cacheControl: OG_CACHE_FALLBACK }; + } + return homeImage; + }; + const fallback = () => (home(), homeFallback!); + const admitRender = () => { + const now = clock(); + while (renders.length && renders[0] <= now - 60_000) renders.shift(); + if (renders.length >= rendersPerMinute) return false; + renders.push(now); + return true; + }; + const remember = (key: string, image: OgImage) => { + lru.set(key, image); + bytes += image.body.length; + for (const [oldest, value] of lru) { + if (lru.size <= maxEntries && bytes <= maxBytes) break; + lru.delete(oldest); + bytes -= value.body.length; + } + }; + + /** Invalid owner/repo rejects (GitHubError 400); every other problem degrades to the generic card. */ + const world = async (owner: string, repo: string, styleParam: unknown): Promise => { + const input = `${owner}/${repo}`; + parseRepo(input); + const style: StyleId = isStyleId(styleParam) ? styleParam : "moonshot"; + let snapshot: WorldSnapshot | null; + let preview: ReturnType; + try { + snapshot = await options.getCached(input); + preview = projectRepositoryPreview(input, snapshot); + } catch { + return fallback(); + } + if (!snapshot || preview.nodes.length === 0) return fallback(); + const key = `${preview.fullName.toLowerCase()}|${style}|${preview.headSha ?? snapshot.collectedAt}`; + const hit = lru.get(key); + if (hit) { + lru.delete(key); + lru.set(key, hit); + return hit; + } + if (!admitRender()) return fallback(); + const card: CardWorld = { + fullName: preview.fullName, + nodes: preview.nodes as unknown as WorldNode[], + edges: preview.edges as unknown as WorldEdge[], + totalNodes: preview.totalNodes, + totalEdges: preview.totalEdges, + stars: Number.isFinite(snapshot.repo.stars) ? snapshot.repo.stars : null, + language: typeof snapshot.repo.language === "string" ? snapshot.repo.language : null, + description: typeof snapshot.repo.description === "string" ? snapshot.repo.description : null, + }; + let body: Uint8Array; + try { + body = render.world(card, style, { site, deadline: clock() + renderMs }); + } catch { + return fallback(); + } + const image: OgImage = { body, etag: etagOf(body), cacheControl: OG_CACHE_WORLD, kind: "world" }; + remember(key, image); + return image; + }; + return { home, world, cacheSize: () => lru.size }; +} + +/** Display-only site line, e.g. "chaorders.com/github-factory". */ +export function siteLabel(origin: string | null, basePath: string) { + const valid = normalizeOrigin(origin); + return valid ? `${new URL(valid).host}${basePath.replace(/\/$/, "")}` : null; +} + +export function imageResponse(request: Request, image: OgImage) { + const headers = { + "content-type": "image/png", + "cache-control": image.cacheControl, + etag: image.etag, + "x-content-type-options": "nosniff", + "content-security-policy": "default-src 'none'", + "x-og-image": image.kind, + }; + const match = request.headers.get("if-none-match"); + if (match && match.split(",").some((tag) => tag.trim().replace(/^W\//, "") === image.etag)) return new Response(null, { status: 304, headers }); + return new Response(request.method === "HEAD" ? null : (image.body as Uint8Array), { status: 200, headers: { ...headers, "content-length": String(image.body.length) } }); +} diff --git a/src/server/og-routes.ts b/src/server/og-routes.ts new file mode 100644 index 0000000..48ae83d --- /dev/null +++ b/src/server/og-routes.ts @@ -0,0 +1,30 @@ +import { appPath } from "../base-path"; +import { accessConfig, clientIdentity } from "./access"; +import { apiError } from "./http"; +import { createOgImageService, imageResponse, siteLabel } from "./og-image"; +import { hasConfiguredOrigin, serverPublicOrigin } from "./public-origin"; +import { projectEnv } from "./config"; +import { getCachedWorld } from "./service"; + +let service: ReturnType | undefined; +const og = () => (service ??= createOgImageService({ + getCached: getCachedWorld, + site: hasConfiguredOrigin(projectEnv()) ? siteLabel(serverPublicOrigin(), appPath("")) : null, +})); + +export async function serveHomeImage(request: Request) { + try { + clientIdentity(request, accessConfig()); + return imageResponse(request, og().home()); + } catch (error) { + return apiError(error); + } +} +export async function serveWorldImage(request: Request, owner: string, repo: string) { + try { + clientIdentity(request, accessConfig()); + return imageResponse(request, await og().world(owner, repo, new URL(request.url).searchParams.get("style"))); + } catch (error) { + return apiError(error); + } +} diff --git a/src/server/public-origin.ts b/src/server/public-origin.ts new file mode 100644 index 0000000..ee2da91 --- /dev/null +++ b/src/server/public-origin.ts @@ -0,0 +1,19 @@ +import { normalizeOrigin } from "../og/meta"; +import { projectEnv } from "./config"; + +const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]); + +/** Absolute public origin for share links. Only server configuration counts: + * MOONSHOT_PUBLIC_ORIGIN when it is https (or http on loopback), otherwise the + * local default. Host and forwarded headers are never consulted. */ +export function resolvePublicOrigin(env: Record) { + const configured = normalizeOrigin(env.MOONSHOT_PUBLIC_ORIGIN); + if (configured && (configured.startsWith("https://") || LOOPBACK.has(new URL(configured).hostname))) return configured; + const port = Number(env.PORT); + return `http://localhost:${Number.isInteger(port) && port > 0 && port < 65536 ? port : 3000}`; +} +/** True only when the operator configured a real public origin. */ +export const hasConfiguredOrigin = (env: Record) => resolvePublicOrigin(env) === normalizeOrigin(env.MOONSHOT_PUBLIC_ORIGIN); + +let cached: string | undefined; +export const serverPublicOrigin = () => (cached ??= resolvePublicOrigin(projectEnv())); From a506e78d0a887455f338b9e4d63a536756a384f3 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:26 -0800 Subject: [PATCH 04/17] feat(meta): Open Graph and Twitter share meta on home and world pages Co-Authored-By: Claude Opus 5.5 --- src/og/origin.ts | 8 ++++++++ src/routes/__root.tsx | 9 ++++----- src/routes/index.tsx | 4 +++- src/routes/world.$owner.$repo.tsx | 8 +++++++- 4 files changed, 22 insertions(+), 7 deletions(-) create mode 100644 src/og/origin.ts diff --git a/src/og/origin.ts b/src/og/origin.ts new file mode 100644 index 0000000..a872856 --- /dev/null +++ b/src/og/origin.ts @@ -0,0 +1,8 @@ +import { createIsomorphicFn } from "@tanstack/react-start"; +import { serverPublicOrigin } from "../server/public-origin"; + +/** Server: configured MOONSHOT_PUBLIC_ORIGIN (never the Host header). + * Client: the page's own origin, used only after hydration. */ +export const publicOrigin = createIsomorphicFn() + .server(() => serverPublicOrigin()) + .client(() => window.location.origin); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 6b234fe..eb89265 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -2,16 +2,15 @@ import { appPath } from "../base-path"; import { createRootRoute, HeadContent, Scripts, Link } from "@tanstack/react-router"; import type { ReactNode } from "react"; import appCss from "../styles.css?url"; +import { homeMeta } from "../og/meta"; +import { publicOrigin } from "../og/origin"; export const Route = createRootRoute({ head: () => ({ meta: [ { charSet: "utf-8" }, { name: "viewport", content: "width=device-width, initial-scale=1" }, - { title: "Moon Factory — Your codebase, a living world" }, - { - name: "description", - content: "Turn a public GitHub repository into a living, explorable world. Five perspectives. One source of truth.", - }, + // Default share card; routes override by name/property. + ...homeMeta(publicOrigin()), ], links: [ { rel: "stylesheet", href: appCss }, diff --git a/src/routes/index.tsx b/src/routes/index.tsx index b9e9a79..5a2e0e4 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,6 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { LandingPage } from "../components/LandingPage"; -export const Route = createFileRoute("/")({ component: Home }); +import { homeMeta } from "../og/meta"; +import { publicOrigin } from "../og/origin"; +export const Route = createFileRoute("/")({ head: () => ({ meta: homeMeta(publicOrigin()) }), component: Home }); function Home() { return ; } diff --git a/src/routes/world.$owner.$repo.tsx b/src/routes/world.$owner.$repo.tsx index a160d5f..f61f20f 100644 --- a/src/routes/world.$owner.$repo.tsx +++ b/src/routes/world.$owner.$repo.tsx @@ -1,6 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { ExplorerPage } from "../components/ExplorerPage"; -export const Route = createFileRoute("/world/$owner/$repo")({ validateSearch: (search: Record) => search, component: WorldPage }); +import { worldMeta } from "../og/meta"; +import { publicOrigin } from "../og/origin"; +export const Route = createFileRoute("/world/$owner/$repo")({ + validateSearch: (search: Record) => search, + head: ({ params, match }) => ({ meta: worldMeta(publicOrigin(), params.owner, params.repo, (match.search as Record).style) }), + component: WorldPage, +}); function WorldPage() { const { owner, repo } = Route.useParams(); const search = Route.useSearch(); From 74d7b64453fed1339f57ba34ad3b9cc3a6d40501 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:26 -0800 Subject: [PATCH 05/17] test(og): PNG validity, meta escaping, base path and cache-miss fallback Co-Authored-By: Claude Opus 5.5 --- e2e/share-preview.spec.ts | 34 ++++++ test/og.test.ts | 230 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 e2e/share-preview.spec.ts create mode 100644 test/og.test.ts diff --git a/e2e/share-preview.spec.ts b/e2e/share-preview.spec.ts new file mode 100644 index 0000000..6e742ba --- /dev/null +++ b/e2e/share-preview.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +// Server-rendered share metadata as a crawler sees it (no JavaScript), plus the +// image endpoints. The offline runtime has no cached GitHub world, so world +// cards must fall back to the generic card without any outbound request. +const meta = (html: string, key: string) => + html.match(new RegExp(` { + const base = new URL(baseURL!); + const html = await (await request.get("world/synthetic-moonshot/browser-fixture?style=factorio")).text(); + expect(html).toContain("synthetic-moonshot/browser-fixture as a Factorio world · Moon Factory"); + expect(meta(html, "og:image")).toMatch(new RegExp(`^https?://[^/]+${base.pathname}og/world/synthetic-moonshot/browser-fixture\\?style=factorio$`)); + expect(meta(html, "og:url")).toMatch(new RegExp(`^https?://[^/]+${base.pathname}world/synthetic-moonshot/browser-fixture\\?style=factorio$`)); + expect(meta(html, "twitter:card")).toBe("summary_large_image"); + expect(html.match(/property="og:image"/g)).toHaveLength(1); + const home = await (await request.get("")).text(); + expect(meta(home, "og:image")).toMatch(new RegExp(`${base.pathname}og/home\\.png$`)); + expect(meta(home, "theme-color")).toBeTruthy(); +}); + +test("SYNTHETIC share images are PNGs; a cache miss serves the generic card briefly", async ({ request }) => { + const home = await request.get("og/home.png"); + expect(home.status()).toBe(200); + expect(home.headers()["content-type"]).toBe("image/png"); + const body = await home.body(); + expect([...body.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect([body.readUInt32BE(16), body.readUInt32BE(20)]).toEqual([1200, 630]); + const miss = await request.get("og/world/synthetic-moonshot/browser-fixture?style=civilization"); + expect(miss.status()).toBe(200); + expect(miss.headers()["x-og-image"]).toBe("home"); + expect(miss.headers()["cache-control"]).toBe("public, max-age=300, s-maxage=600"); + expect((await request.get("og/world/..%2F/x")).status()).toBe(400); +}); diff --git a/test/og.test.ts b/test/og.test.ts new file mode 100644 index 0000000..8a62aec --- /dev/null +++ b/test/og.test.ts @@ -0,0 +1,230 @@ +import { expect, spyOn, test } from "bun:test"; +import { inflateSync } from "node:zlib"; +import { createElement, Fragment } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { STYLE_IDS, type WorldSnapshot } from "../src/contracts"; +import { renderHomeCard, renderWorldCard, TAGLINE, type CardWorld } from "../src/og/card"; +import { asciiShare, drawableText } from "../src/og/font"; +import { homeMeta, normalizeOrigin, validRepoParams, worldMeta } from "../src/og/meta"; +import { crc32, encodePng } from "../src/og/png"; +import { RenderBudgetError } from "../src/og/raster"; +import { parseRepo } from "../src/server/github"; +import { createOgImageService, imageResponse, OG_CACHE_FALLBACK, OG_CACHE_HOME, OG_CACHE_WORLD, siteLabel } from "../src/server/og-image"; +import { resolvePublicOrigin } from "../src/server/public-origin"; +import { syntheticWorld } from "./fixtures/browser-world"; + +const ORIGIN = "https://share.example"; +type Tag = { title?: string; name?: string; property?: string; content?: string }; +const content = (meta: Tag[], key: string) => meta.find((m) => m.name === key || m.property === key)?.content; + +function chunks(png: Uint8Array) { + const view = new DataView(png.buffer, png.byteOffset, png.byteLength), out: { type: string; data: Uint8Array; crcOk: boolean }[] = []; + for (let o = 8; o < png.length;) { + const length = view.getUint32(o), type = String.fromCharCode(...png.subarray(o + 4, o + 8)); + const data = png.subarray(o + 8, o + 8 + length); + out.push({ type, data, crcOk: crc32(png.subarray(o + 4, o + 8 + length)) === view.getUint32(o + 8 + length) }); + o += 12 + length; + } + return out; +} +function expectPng(png: Uint8Array, width: number, height: number) { + expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const list = chunks(png); + expect(list.map((c) => c.type)).toEqual(["IHDR", "IDAT", "IEND"]); + expect(list.every((c) => c.crcOk)).toBe(true); + const ihdr = new DataView(list[0].data.buffer, list[0].data.byteOffset, 13); + expect([ihdr.getUint32(0), ihdr.getUint32(4), list[0].data[8], list[0].data[9]]).toEqual([width, height, 8, 2]); + expect(inflateSync(list[1].data).length).toBe((width * 3 + 1) * height); +} +/** SYNTHETIC verified-source snapshot: test-only flag, never live GitHub evidence. */ +function verifiedWorld(headSha = "1234567890abcdef1234567890abcdef12345678"): WorldSnapshot { + const world = syntheticWorld(); + world.source = "github"; + world.headSha = headSha; + world.refreshAfter = Date.now() + 60_000; + return world; +} +function card(): CardWorld { + const world = syntheticWorld(); + return { fullName: world.repo.fullName, nodes: world.nodes, edges: world.edges, totalNodes: world.nodes.length, totalEdges: world.edges.length, stars: 42, language: "TypeScript", description: world.repo.description }; +} + +test("encodePng emits a valid RGB PNG with correct CRCs and scanlines", () => { + expect(crc32(new TextEncoder().encode("IEND"))).toBe(0xae426082); + const rgb = new Uint8Array(5 * 3 * 3).map((_, i) => (i * 37) & 0xff); + expectPng(encodePng(5, 3, rgb), 5, 3); + expect(() => encodePng(5, 3, new Uint8Array(4))).toThrow(); +}); + +test("SYNTHETIC world and homepage cards are 1200x630 PNGs in every style", () => { + for (const style of STYLE_IDS) expectPng(renderWorldCard(card(), style), 1200, 630); + expectPng(renderHomeCard({ site: "share.example/github-factory" }), 1200, 630); + expect(TAGLINE).toBe("Every codebase. A world of its own."); +}); + +test("rendering honours its CPU deadline", () => { + expect(() => renderWorldCard(card(), "factorio", { deadline: performance.now() - 1 })).toThrow(RenderBudgetError); +}); + +test("image text is reduced to the drawable ASCII glyph set", () => { + expect(drawableText("Café “quoted”", 100)).toBe('Cafe "quoted"'); + for (const ch of drawableText("Café “quoted” — 日本 ‮\u0000 🚀", 100)) expect(ch.charCodeAt(0) < 0x7f || "·…★•".includes(ch)).toBe(true); + expect(drawableText("x".repeat(500), 40).length).toBeLessThanOrEqual(40); + expect(asciiShare("日本語のリポジトリ")).toBeLessThan(0.5); +}); + +test("home meta uses absolute origin + base path for page and image", () => { + const meta = homeMeta(ORIGIN) as Tag[]; + expect(content(meta, "og:url")).toBe(`${ORIGIN}/github-factory/`); + expect(content(meta, "og:image")).toBe(`${ORIGIN}/github-factory/og/home.png`); + expect(content(meta, "twitter:image")).toBe(`${ORIGIN}/github-factory/og/home.png`); + expect(content(meta, "twitter:card")).toBe("summary_large_image"); + expect(content(meta, "theme-color")).toMatch(/^#[0-9a-f]{6}$/); + expect(meta.find((m) => m.title)?.title).toBe("Moon Factory — Your codebase, a living world"); + expect(content(meta, "og:description")).toContain("Every codebase. A world of its own."); +}); + +test("world meta is per repository and style; invalid params fall back to generic meta", () => { + const meta = worldMeta(ORIGIN, "openclaw", "openclaw", "factorio") as Tag[]; + expect(meta.find((m) => m.title)?.title).toBe("openclaw/openclaw as a Factorio world · Moon Factory"); + expect(content(meta, "og:url")).toBe(`${ORIGIN}/github-factory/world/openclaw/openclaw?style=factorio`); + expect(content(meta, "og:image")).toBe(`${ORIGIN}/github-factory/og/world/openclaw/openclaw?style=factorio`); + const unstyled = worldMeta(ORIGIN, "vercel", "next.js", "', "x"], ["ok", "..",], ["-bad", "x"], ["ok", "a/b"], ["ok", "x".repeat(101)], [undefined, "x"]]) + expect(worldMeta(ORIGIN, owner, repo, "factorio")).toEqual(homeMeta(ORIGIN)); +}); + +test("rendered meta escapes every attribute value", () => { + const hostile = '">&'; + const meta = [...(worldMeta(ORIGIN, hostile, hostile, hostile) as Tag[]), { name: "x", content: hostile }]; + const html = renderToStaticMarkup(createElement(Fragment, null, ...meta.filter((m) => !m.title).map((m, i) => createElement("meta", { key: i, ...m })))); + expect(html).not.toContain(""'`]/); +}); + +test("meta validation agrees with the server repository parser", () => { + const owners = ["a", "a-b", "-a", "a-", "A1", "a".repeat(39), "a".repeat(40), "a_b", "a.b", "", "日本"]; + const repos = ["r", "r.git", "next.js", ".", "..", "a_b-c", "r".repeat(100), "r".repeat(101), "a b", "a/b", "%2e%2e"]; + for (const owner of owners) for (const repo of repos) { + let server: string | null = null; + try { server = parseRepo(`${owner}/${repo}`).fullName; } catch { server = null; } + const local = validRepoParams(owner, repo); + expect(local ? `${local.owner}/${local.repo}` : null).toBe(server); + } +}); + +test("public origin comes only from configuration, never the request", () => { + expect(resolvePublicOrigin({ MOONSHOT_PUBLIC_ORIGIN: "https://chaorders.com" })).toBe("https://chaorders.com"); + expect(resolvePublicOrigin({ MOONSHOT_PUBLIC_ORIGIN: "https://chaorders.com/" })).toBe("https://chaorders.com"); + expect(resolvePublicOrigin({ MOONSHOT_PUBLIC_ORIGIN: "http://127.0.0.1:4362" })).toBe("http://127.0.0.1:4362"); + for (const bad of ["http://chaorders.com", "https://chaorders.com/github-factory/", "https://u:p@chaorders.com", "javascript:alert(1)", "chaorders.com", ""]) + expect(resolvePublicOrigin({ MOONSHOT_PUBLIC_ORIGIN: bad, PORT: "4362" })).toBe("http://localhost:4362"); + expect(resolvePublicOrigin({})).toBe("http://localhost:3000"); + expect(resolvePublicOrigin({ PORT: "99999" })).toBe("http://localhost:3000"); + expect(normalizeOrigin("https://a.example?x")).toBeNull(); + expect(siteLabel("https://chaorders.com", "/github-factory/")).toBe("chaorders.com/github-factory"); + expect(siteLabel(null, "/github-factory/")).toBeNull(); +}); + +function stubRenderers() { + const calls: string[] = []; + return { + calls, + render: { + world: (world: CardWorld, style: string) => { calls.push(`${world.fullName}|${style}`); return new TextEncoder().encode(`world:${world.fullName}:${style}`); }, + home: () => { calls.push("home"); return new TextEncoder().encode("home"); }, + }, + }; +} + +test("SYNTHETIC cache miss serves the generic card without network, collection or writes", async () => { + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async () => { throw new Error("network forbidden"); }) as unknown as typeof fetch); + try { + let reads = 0; + const service = createOgImageService({ getCached: async () => { reads++; return null; } }); + const image = await service.world("openclaw", "openclaw", "factorio"); + expect(reads).toBe(1); + expect(image.kind).toBe("home"); + expect(image.cacheControl).toBe(OG_CACHE_FALLBACK); + expectPng(image.body, 1200, 630); + expect(service.home().cacheControl).toBe(OG_CACHE_HOME); + expect(service.home().etag).toBe(image.etag); + expect(service.cacheSize()).toBe(0); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { fetchSpy.mockRestore(); } +}); + +test("SYNTHETIC unverified, empty, malformed or failing cache reads degrade to the generic card", async () => { + const { render, calls } = stubRenderers(); + const demo = syntheticWorld(); + const empty = verifiedWorld(); + empty.coverage.tree = "unavailable"; + const malformed = verifiedWorld() as unknown as { nodes: unknown }; + malformed.nodes = "nope"; + for (const getCached of [async () => demo, async () => empty, async () => malformed as unknown as WorldSnapshot, async () => { throw new Error("storage down"); }]) { + const image = await createOgImageService({ getCached, render }).world("synthetic-moonshot", "browser-fixture", "factorio"); + expect(image.kind).toBe("home"); + } + expect(calls.filter((c) => c !== "home")).toEqual([]); +}); + +test("invalid owner/repo is rejected before any cache read", async () => { + let reads = 0; + const service = createOgImageService({ getCached: async () => { reads++; return null; }, render: stubRenderers().render }); + for (const [owner, repo] of [["..", "x"], ["a", ".."], ["-a", "b"], ["a", "b/c"]]) + await expect(service.world(owner, repo, "factorio")).rejects.toMatchObject({ status: 400 }); + expect(reads).toBe(0); +}); + +test("SYNTHETIC cached worlds render once per repo + style + head (LRU) and use CDN caching", async () => { + const { render, calls } = stubRenderers(); + let world = verifiedWorld(); + const service = createOgImageService({ getCached: async () => world, render, maxEntries: 2 }); + const first = await service.world("synthetic-moonshot", "browser-fixture", "factorio"); + expect(first).toMatchObject({ kind: "world", cacheControl: OG_CACHE_WORLD }); + expect(await service.world("SYNTHETIC-MOONSHOT", "browser-fixture", "factorio")).toBe(first); + await service.world("synthetic-moonshot", "browser-fixture", "not-a-style"); + expect(calls).toEqual(["synthetic-moonshot/browser-fixture|factorio", "synthetic-moonshot/browser-fixture|moonshot"]); + world = verifiedWorld("abcdefabcdefabcdefabcdefabcdefabcdefabcd"); + await service.world("synthetic-moonshot", "browser-fixture", "factorio"); + expect(calls).toHaveLength(3); + expect(service.cacheSize()).toBe(2); +}); + +test("SYNTHETIC render rate limit and render budget failures fall back to the generic card", async () => { + let now = 0; + const { render, calls } = stubRenderers(); + const limited = createOgImageService({ getCached: async () => verifiedWorld(), render, rendersPerMinute: 1, clock: () => now }); + expect((await limited.world("synthetic-moonshot", "browser-fixture", "factorio")).kind).toBe("world"); + expect((await limited.world("synthetic-moonshot", "browser-fixture", "civilization")).kind).toBe("home"); + now = 60_001; + expect((await limited.world("synthetic-moonshot", "browser-fixture", "civilization")).kind).toBe("world"); + expect(calls.filter((c) => c !== "home")).toHaveLength(2); + const slow = createOgImageService({ getCached: async () => verifiedWorld(), render: { ...render, world: () => { throw new RenderBudgetError(); } } }); + expect(await slow.world("synthetic-moonshot", "browser-fixture", "factorio")).toMatchObject({ kind: "home", cacheControl: OG_CACHE_FALLBACK }); + expect(slow.cacheSize()).toBe(0); +}); + +test("SYNTHETIC real world render through the service is a PNG of the cached districts", async () => { + const service = createOgImageService({ getCached: async () => verifiedWorld(), site: "share.example/github-factory" }); + const image = await service.world("synthetic-moonshot", "browser-fixture", "civilization"); + expect(image.kind).toBe("world"); + expectPng(image.body, 1200, 630); +}); + +test("image responses support ETag revalidation and HEAD", async () => { + const image = { body: new Uint8Array([1, 2, 3]), etag: '"og-abc"', cacheControl: OG_CACHE_WORLD, kind: "world" as const }; + const ok = imageResponse(new Request("http://localhost/og"), image); + expect(ok.status).toBe(200); + expect(ok.headers.get("content-type")).toBe("image/png"); + expect(ok.headers.get("x-content-type-options")).toBe("nosniff"); + expect([...new Uint8Array(await ok.arrayBuffer())]).toEqual([1, 2, 3]); + expect(imageResponse(new Request("http://localhost/og", { headers: { "if-none-match": 'W/"og-abc"' } }), image).status).toBe(304); + const head = imageResponse(new Request("http://localhost/og", { method: "HEAD" }), image); + expect(head.headers.get("content-length")).toBe("3"); + expect(await head.text()).toBe(""); +}); From 2aa999ce31e8d718750b0a7944091cf64fb7990a Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:28 -0800 Subject: [PATCH 06/17] feat(atlas): exponential camera easing with anchored log-space zoom Co-Authored-By: Claude Opus 5.5 --- src/atlas/camera.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/atlas/camera.ts b/src/atlas/camera.ts index a6a8bf7..04c0cef 100644 --- a/src/atlas/camera.ts +++ b/src/atlas/camera.ts @@ -36,4 +36,17 @@ export function zoomAt(c: Camera, factor: number, point: Point, s: Size) { return constrain({ ...bounded, centerX: bounded.centerX + before.x - after.x, centerY: bounded.centerY + before.y - after.y }, s); } export function resizeCamera(c: Camera, old: Size, next: Size, home: Bounds) { return constrain({ ...c, zoom: fit(home, next).zoom * c.zoom / fit(home, old).zoom }, next); } +/** One exponential easing step from `from` toward `to` over `dt` ms (zoom eases + * in log space). With an `anchor`, the anchored world point stays under the same + * screen point throughout, so pointer zoom does not drift. Returns null once the + * remaining motion is below a quarter pixel: the caller then snaps to `to`. */ +export function easeCamera(from: Camera, to: Camera, dt: number, s: Size, anchor?: { screen: Point; world: Point } | null, tau = 110): Camera | null { + const k = 1 - Math.exp(-Math.max(0, Math.min(dt, 100)) / tau); + const zoom = Math.exp(Math.log(from.zoom) + (Math.log(to.zoom) - Math.log(from.zoom)) * k); + let next: Camera = { centerX: from.centerX + (to.centerX - from.centerX) * k, centerY: from.centerY + (to.centerY - from.centerY) * k, zoom }; + if (anchor && Math.abs(Math.log(to.zoom / from.zoom)) > 1e-6) + next = constrain({ zoom, centerX: anchor.world.x - (anchor.screen.x - s.width / 2) / zoom, centerY: anchor.world.y - (anchor.screen.y - s.height / 2) / zoom }, s); + const pixels = Math.hypot(next.centerX - to.centerX, next.centerY - to.centerY) * to.zoom, zoomGap = Math.abs(Math.log(next.zoom / to.zoom)); + return pixels < .25 && zoomGap < 1e-3 ? null : next; +} export const wheelFactor = (delta: number, mode: number, height: number) => Math.exp(-clamp(delta * (mode === 1 ? 16 : mode === 2 ? height : 1), -240, 240) * .0015); From d4d10ea821203a259bf8a371cdc4f8ecde9f7e43 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:28 -0800 Subject: [PATCH 07/17] feat(atlas): parallax star map with style planets, ghosted unchecked landmarks and capped ambient motion Paints a parallax star field, editorial nebulae, scenic routes and planet sprites that hint at each landmark's suggested style. Unchecked, missing or unavailable landmarks render as dashed ghost outlines with ?/x/! glyphs. The scheduler eases the camera at display rate, caps ambient motion near 30fps and stops entirely when the tab or map pane is hidden or the user prefers reduced motion. data-motion exposes the state for tests. Co-Authored-By: Claude Opus 5.5 --- src/atlas/AtlasCanvas.tsx | 132 +++++++++--- src/atlas/paint.ts | 429 ++++++++++++++++++++++++-------------- src/atlas/scenery.ts | 261 +++++++++++++++++++++++ 3 files changed, 633 insertions(+), 189 deletions(-) create mode 100644 src/atlas/scenery.ts diff --git a/src/atlas/AtlasCanvas.tsx b/src/atlas/AtlasCanvas.tsx index 42a4108..b5f72db 100644 --- a/src/atlas/AtlasCanvas.tsx +++ b/src/atlas/AtlasCanvas.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; -import { constrain, fit, pan, resizeCamera, wheelFactor, zoomAt, type Camera, type Point, type Size } from "./camera"; -import { homeBounds, type CacheStatus, type Landmark } from "./model"; +import { constrain, easeCamera, fit, pan, resizeCamera, unproject, wheelFactor, zoomAt, type Camera, type Point, type Size } from "./camera"; +import { homeBounds, statusText, type CacheStatus, type Landmark } from "./model"; import { contains, paintAtlas, type HitTarget } from "./paint"; +import { createScenery } from "./scenery"; interface Props { landmarks: Landmark[]; statuses: Map; @@ -20,15 +21,20 @@ interface Props { onClear: () => void; onView: (camera: Camera, homeZoom: number, commit: boolean) => void; } +type Change = { animate?: boolean; anchor?: { screen: Point; world: Point } | null }; +/** Ambient scenery is capped near 30fps; camera easing runs at display rate. */ +const AMBIENT_MS = 1000 / 30 - 4, STILL_TIME = 4.2; export function AtlasCanvas(props: Props) { const canvas = useRef(null), wrap = useRef(null), data = useRef(props); data.current = props; const view = useRef<{ camera: Camera; + target: Camera; + anchor: Change["anchor"]; size: Size; initialized: boolean; - }>({ camera: { centerX: 1400, centerY: 1000, zoom: .2 }, size: { width: 1, height: 1 }, initialized: false }); - const targets = useRef([]), invalidate = useRef(() => { }), commitView = useRef<(c: Camera, commit?: boolean) => void>(() => { }), cancelGesture = useRef<(silent?: boolean) => void>(() => { }); + }>({ camera: { centerX: 1400, centerY: 1000, zoom: .2 }, target: { centerX: 1400, centerY: 1000, zoom: .2 }, anchor: null, size: { width: 1, height: 1 }, initialized: false }); + const targets = useRef([]), invalidate = useRef(() => { }), commitView = useRef<(c: Camera, commit?: boolean, how?: Change) => void>(() => { }), cancelGesture = useRef<(silent?: boolean) => void>(() => { }); const pointers = useRef(new Map()), gesture = useRef<{ mode: "drag" | "pinch"; camera: Camera; @@ -40,55 +46,97 @@ export function AtlasCanvas(props: Props) { } | null>(null), suppressTap = useRef(false); const [unavailable, setUnavailable] = useState(false), [tooltip, setTooltip] = useState<{ text: string; + detail: string; x: number; y: number; key: string; } | null>(null); - const hoverTimer = useRef>(), hoverKey = useRef(null); + const hoverTimer = useRef>(), hoverKey = useRef(null), hoverMix = useRef(0); + const setHover = (key: string | null) => { if (hoverKey.current === key) return; hoverKey.current = key; hoverMix.current = 0; if (canvas.current) canvas.current.style.cursor = key ? "pointer" : ""; invalidate.current(); }; const clearHover = () => { if (hoverTimer.current) - clearTimeout(hoverTimer.current); hoverTimer.current = undefined; hoverKey.current = null; setTooltip(null); invalidate.current(); }; - const change = (c: Camera, commit = false) => commitView.current(c, commit); - const home = () => change(fit(homeBounds(data.current.landmarks), view.current.size), true); + clearTimeout(hoverTimer.current); hoverTimer.current = undefined; setHover(null); setTooltip(null); }; + const change = (c: Camera, commit = false, how: Change = {}) => commitView.current(c, commit, how); + const center = () => ({ x: view.current.size.width / 2, y: view.current.size.height / 2 }); + const zoomBy = (factor: number, at: Point = center()) => { const v = view.current; change(zoomAt(v.target, factor, at, v.size), true, { animate: true, anchor: { screen: at, world: unproject(at, v.target, v.size) } }); }; + const home = () => change(fit(homeBounds(data.current.landmarks), view.current.size), true, { animate: true }); const locate = (key: string | null) => { const p = data.current.landmarks.find(x => x.repoKey === key); if (p) - change({ ...view.current.camera, centerX: p.x, centerY: p.y }, true); }; + change({ ...view.current.target, centerX: p.x, centerY: p.y }, true, { animate: true }); }; useEffect(() => { const cv = canvas.current, el = wrap.current; if (!cv || !el) return; - let raf = 0, visible = true, disposed = false, lastPaint = -Infinity; + let raf = 0, timer: ReturnType | 0 = 0, visible = true, disposed = false, stepAt = 0, motionState = ""; const context = cv.getContext("2d"); if (!context) { setUnavailable(true); return; } + const scenery = createScenery(context), started = performance.now(), reduced = window.matchMedia("(prefers-reduced-motion: reduce)"); + let motion = !reduced.matches; const eligible = () => !disposed && visible && !document.hidden && el.clientWidth > 0 && el.clientHeight > 0; - const request = () => { if (!raf && eligible()) + const request = () => { if (timer) { + clearTimeout(timer); + timer = 0; + } if (!raf && eligible()) raf = requestAnimationFrame(draw); }; + const mark = (state: string) => { if (state !== motionState) { + motionState = state; + el.dataset.motion = state; + } }; const draw = (now: number) => { raf = 0; if (!eligible()) return; - if (now - lastPaint < 1000 / 30) { - request(); - return; + const v = view.current, dt = Math.max(0, now - (stepAt || now)), size = v.size, ratio = cv.width / size.width; + stepAt = now; + let moving = false; + if (v.camera !== v.target) { + const next = motion ? easeCamera(v.camera, v.target, dt, size, v.anchor) : null; + v.camera = next ?? v.target; + if (next) + moving = true; + else + v.anchor = null; } - lastPaint = now; - const { size, camera } = view.current, ratio = cv.width / size.width; + const easingHover = motion && !!hoverKey.current && hoverMix.current < 1; + hoverMix.current = motion ? Math.min(1, hoverMix.current + dt / 140) : 1; context.setTransform(ratio, 0, 0, ratio, 0, 0); - targets.current = paintAtlas(context, data.current.landmarks, camera, size, data.current.statuses, data.current.selected, hoverKey.current, data.current.lit); + targets.current = paintAtlas(context, { points: data.current.landmarks, camera: v.camera, size, statuses: data.current.statuses, selected: data.current.selected, hover: hoverKey.current, hoverMix: hoverMix.current, lit: data.current.lit ?? new Set(), time: motion ? (now - started) / 1000 : STILL_TIME, animated: motion, ratio, scenery }); + mark(moving ? "camera" : motion ? "ambient" : "still"); + if (moving || easingHover) + request(); + else if (motion) + timer = setTimeout(() => { timer = 0; request(); }, AMBIENT_MS); }; invalidate.current = request; - commitView.current = (camera, commit = false) => { view.current.camera = constrain(camera, view.current.size); data.current.onView(view.current.camera, fit(homeBounds(data.current.landmarks), view.current.size).zoom, commit); request(); }; + commitView.current = (camera, commit = false, how = {}) => { + const v = view.current, wasMoving = v.camera !== v.target; + v.target = constrain(camera, v.size); + if (how.animate && motion) { + v.anchor = how.anchor ?? null; + if (!wasMoving) + stepAt = performance.now(); + } + else { + v.camera = v.target; + v.anchor = null; + } + data.current.onView(v.target, fit(homeBounds(data.current.landmarks), v.size).zoom, commit); + request(); + }; const resize = () => { const rect = el.getBoundingClientRect(), size = { width: rect.width, height: rect.height }; if (size.width < 1 || size.height < 1) return; const old = view.current, home = homeBounds(data.current.landmarks), homeCamera = fit(home, size), saved = data.current.initialView; - const camera = old.initialized ? resizeCamera(old.camera, old.size, size, home) : saved ? constrain({ centerX: saved.centerX, centerY: saved.centerY, zoom: homeCamera.zoom * saved.zoomRatio }, size) : constrain(homeCamera, size); + const camera = old.initialized ? resizeCamera(old.target, old.size, size, home) : saved ? constrain({ centerX: saved.centerX, centerY: saved.centerY, zoom: homeCamera.zoom * saved.zoomRatio }, size) : constrain(homeCamera, size); const pixels = size.width < 600 ? 1500000 : 4000000, ratio = Math.min(1.5, window.devicePixelRatio || 1, Math.sqrt(pixels / (size.width * size.height))); cv.width = Math.max(1, Math.floor(size.width * ratio)); cv.height = Math.max(1, Math.floor(size.height * ratio)); - view.current = { camera, size, initialized: true }; + // First arrival without a saved view eases in from a wider shot (motion only). + const intro = !old.initialized && !saved && motion ? constrain({ ...camera, zoom: camera.zoom * .78 }, size) : camera; + view.current = { camera: intro, target: camera, anchor: null, size, initialized: true }; + stepAt = performance.now(); data.current.onView(camera, homeCamera.zoom, false); request(); }; @@ -97,7 +145,11 @@ export function AtlasCanvas(props: Props) { resize(); const stop = () => { if (raf) cancelAnimationFrame(raf); + if (timer) clearTimeout(timer); raf = 0; + timer = 0; + stepAt = 0; + mark("paused"); cancelGesture.current(disposed); }; const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; if (visible) @@ -110,13 +162,15 @@ export function AtlasCanvas(props: Props) { else request(); }; document.addEventListener("visibilitychange", visibility); + const onMotion = () => { motion = !reduced.matches; request(); }; + reduced.addEventListener("change", onMotion); const blur = () => cancelGesture.current(); window.addEventListener("blur", blur); const wheel = (e: WheelEvent) => { if (e.ctrlKey || e.metaKey) - return; e.preventDefault(); clearHover(); const r = cv.getBoundingClientRect(); change(zoomAt(view.current.camera, wheelFactor(e.deltaY, e.deltaMode, r.height), { x: e.clientX - r.left, y: e.clientY - r.top }, view.current.size), true); }; + return; e.preventDefault(); clearHover(); const r = cv.getBoundingClientRect(); zoomBy(wheelFactor(e.deltaY, e.deltaMode, r.height), { x: e.clientX - r.left, y: e.clientY - r.top }); }; cv.addEventListener("wheel", wheel, { passive: false }); - return () => { disposed = true; stop(); observer.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", visibility); window.removeEventListener("blur", blur); cv.removeEventListener("wheel", wheel); if (hoverTimer.current) - clearTimeout(hoverTimer.current); cv.width = 0; cv.height = 0; invalidate.current = () => { }; commitView.current = () => { }; }; + return () => { disposed = true; stop(); observer.disconnect(); intersection.disconnect(); document.removeEventListener("visibilitychange", visibility); reduced.removeEventListener("change", onMotion); window.removeEventListener("blur", blur); cv.removeEventListener("wheel", wheel); if (hoverTimer.current) + clearTimeout(hoverTimer.current); scenery.dispose(); cv.width = 0; cv.height = 0; invalidate.current = () => { }; commitView.current = () => { }; }; }, []); useEffect(() => { invalidate.current(); }, [props.landmarks, props.statuses, props.selected, props.lit]); useEffect(() => { if (props.locateRequest) @@ -128,6 +182,10 @@ export function AtlasCanvas(props: Props) { return; clearHover(); const p = point(e); + if (view.current.camera !== view.current.target) { + view.current.target = view.current.camera; + view.current.anchor = null; + } pointers.current.set(e.pointerId, p); e.currentTarget.setPointerCapture(e.pointerId); const all = [...pointers.current.values()]; @@ -141,13 +199,16 @@ export function AtlasCanvas(props: Props) { const move = (e: ReactPointerEvent) => { const p = point(e), g = gesture.current; if (!pointers.current.has(e.pointerId) || !g) { - if (e.pointerType === "mouse" && !props.selected) { + if (e.pointerType === "mouse") { if (hoverTimer.current) clearTimeout(hoverTimer.current); const hit = hits(p); if (hit.length === 1 && hit[0].keys.length === 1) { const key = hit[0].keys[0], landmark = props.landmarks.find(x => x.repoKey === key); - hoverTimer.current = setTimeout(() => { hoverKey.current = key; setTooltip({ key, text: landmark?.fullName ?? key, x: Math.max(8, Math.min(p.x + 12, view.current.size.width - 288)), y: Math.max(8, Math.min(p.y - 48, view.current.size.height - 60)) }); invalidate.current(); }, 120); + setHover(key); + if (props.selected) + return; + hoverTimer.current = setTimeout(() => { setTooltip({ key, text: landmark?.fullName ?? key, detail: `${landmark ? landmark.category + " · " : ""}${statusText(props.statuses.get(key))}`, x: Math.max(8, Math.min(p.x + 16, view.current.size.width - 288)), y: Math.max(8, Math.min(p.y - 64, view.current.size.height - 76)) }); }, 120); } else clearHover(); @@ -195,6 +256,7 @@ export function AtlasCanvas(props: Props) { for (const id of ids) if (cv?.hasPointerCapture(id)) cv.releasePointerCapture(id); if (hoverTimer.current) clearTimeout(hoverTimer.current); hoverKey.current = null; + if (cv) cv.style.cursor = ""; if (!silent) setTooltip(null); }; return
@@ -203,18 +265,18 @@ export function AtlasCanvas(props: Props) { clearHover(); }} onKeyDown={e => { if (e.ctrlKey || e.metaKey || e.altKey) return; - const c = view.current.camera, s = view.current.size, step = e.shiftKey ? 128 : 64; + const s = view.current.size, step = e.shiftKey ? 128 : 64; const deltas: Record = { ArrowLeft: [step, 0], ArrowRight: [-step, 0], ArrowUp: [0, step], ArrowDown: [0, -step] }; if (deltas[e.key]) { e.preventDefault(); - change(pan(c, ...deltas[e.key], s), true); + change(pan(view.current.target, ...deltas[e.key], s), true, { animate: true }); } else if (["+", "=", "-"].includes(e.key)) { e.preventDefault(); - change(zoomAt(c, e.key === "-" ? 1 / 1.2 : 1.2, { x: s.width / 2, y: s.height / 2 }, s), true); + zoomBy(e.key === "-" ? 1 / 1.2 : 1.2); } else if (e.key === "0") { e.preventDefault(); @@ -231,13 +293,19 @@ export function AtlasCanvas(props: Props) { props.onClear(); } }}/>} - {tooltip && !props.selected &&
{tooltip.text}
} + {tooltip && !props.selected &&
{tooltip.text}{tooltip.detail}
}
- - + +
-

Scenic routes, not dependency links · Static scenery

+ +
    +
  • Verified cached capture
  • +
  • ?Unchecked · outline only
  • +
  • ✧Lantern lit
  • +
+

Scenic routes, not dependency links · Illustrative motion, not live data

; } diff --git a/src/atlas/paint.ts b/src/atlas/paint.ts index 03d64be..6e310f6 100644 --- a/src/atlas/paint.ts +++ b/src/atlas/paint.ts @@ -1,6 +1,6 @@ -import { MAP_CONTRAST as C } from "../world/paint"; -import { hash, visibleGroups, type CacheStatus, type Landmark } from "./model"; -import type { Camera, Size } from "./camera"; +import { hash, visibleGroups, type CacheStatus, type Landmark, type ProjectedLandmark } from "./model"; +import { project, type Camera, type Size } from "./camera"; +import { planetLook, rgba, routePoint, STAR_TILE, type Scenery } from "./scenery"; export interface Rect { x: number; y: number; @@ -13,178 +13,293 @@ export interface HitTarget { x: number; y: number; } +export interface AtlasFrame { + points: Landmark[]; + camera: Camera; + size: Size; + statuses: Map; + selected: string | null; + hover: string | null; + /** 0..1 eased hover emphasis for `hover`. */ + hoverMix: number; + lit: ReadonlySet; + /** Seconds on the decorative clock. Frozen under reduced motion. */ + time: number; + animated: boolean; + /** Device pixels per CSS pixel of the backing store (already capped). */ + ratio: number; + scenery: Scenery; +} export const contains = (r: Rect, x: number, y: number) => x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height; const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width + 8 && a.x + a.width + 8 > b.x && a.y < b.y + b.height + 8 && a.y + a.height + 8 > b.y; -const polygon = (g: CanvasRenderingContext2D, points: number[][], fill: string, stroke?: string) => { g.beginPath(); points.forEach(([x, y], i) => i ? g.lineTo(x, y) : g.moveTo(x, y)); g.closePath(); g.fillStyle = fill; g.fill(); if (stroke) { - g.strokeStyle = stroke; - g.lineWidth = 1.5; - g.stroke(); -} }; -function sculpture(g: CanvasRenderingContext2D, x: number, y: number, size: number, variant: number) { - g.save(); - g.translate(x, y); - g.scale(size / 64, size / 64); - g.fillStyle = C.surface; - g.beginPath(); - g.ellipse(0, 14, 40, 16, 0, 0, Math.PI * 2); - g.fill(); - polygon(g, [[-35, 3], [0, -8], [35, 3], [35, 14], [0, 25], [-35, 14]], C.border, C.edge); - polygon(g, [[-35, 3], [0, -8], [35, 3], [0, 14]], C.muted, C.edge); - if (variant === 0) { - polygon(g, [[-23, 0], [0, -9], [23, 0], [23, -13], [0, -22], [-23, -13]], C.surface, C.edge); +const MONO = '"IBM Plex Mono",ui-monospace,SFMono-Regular,Consolas,monospace'; +const SELECT = "#52a8ff", VERIFIED = "#7fe7dc", GOLD = "#ffd27a", INK = "#eef3f4"; +const TAU = Math.PI * 2; +/** On-screen planet diameter in CSS pixels; grows gently with zoom. */ +export const planetSize = (zoom: number) => Math.max(30, Math.min(76, 52 * (zoom / .3) ** .35)); +const mod = (n: number, m: number) => ((n % m) + m) % m; + +/** Paint one Atlas frame. Original procedural scenery only: no textures, tiles, + * physics or cross-repository graph. Routes are scenic, not dependency links. + * The first full-canvas `fillRect` is the frame boundary (tests count it), so + * other full-canvas overlays use `rect()` + `fill()`. Labels use `fillText`. */ +export function paintAtlas(g: CanvasRenderingContext2D, f: AtlasFrame): HitTarget[] { + const { points, camera, size, statuses, selected, hover, lit, time: t, animated, ratio, scenery } = f; + const W = size.width, H = size.height; + // 1. Deep space. + const sky = g.createLinearGradient(0, 0, W * .35, H); + sky.addColorStop(0, "#070b16"); sky.addColorStop(.55, "#0a1020"); sky.addColorStop(1, "#05070d"); + g.fillStyle = sky; + g.fillRect(0, 0, W, H); + // 2. Parallax star field: two cached tiles drifting slower than the map, plus twinkles. + const stars = scenery.stars(ratio); + const layer = (pattern: CanvasPattern | null, depth: number, alpha: number) => { + if (!pattern) return; + const tile = STAR_TILE * ratio, ox = mod(-(camera.centerX * camera.zoom * depth) * ratio - (animated ? t * 1.2 * depth * ratio : 0), tile), oy = mod(-(camera.centerY * camera.zoom * depth) * ratio, tile); + g.save(); + g.setTransform(1, 0, 0, 1, ox - tile, oy - tile); + g.globalAlpha = alpha; + g.fillStyle = pattern; g.beginPath(); - g.ellipse(0, -17, 17, 14, 0, Math.PI, 0); - g.lineTo(17, -13); - g.lineTo(-17, -13); - g.closePath(); - g.fillStyle = C.muted; + g.rect(0, 0, W * ratio + tile * 2, H * ratio + tile * 2); g.fill(); - g.strokeStyle = C.edge; - g.stroke(); + g.restore(); + }; + layer(stars.far, .06, .9); + layer(stars.near, .16, 1); + g.save(); + g.globalCompositeOperation = "lighter"; + const tw = 1024, txo = -(camera.centerX * camera.zoom * .26), tyo = -(camera.centerY * camera.zoom * .26); + for (const s of stars.twinkles) for (let x = mod(s.x + txo, tw); x < W + 8; x += tw) for (let y = mod(s.y + tyo, tw); y < H + 8; y += tw) { + const pulse = animated ? .5 + .5 * Math.sin(t * s.speed + s.phase) : .7, len = s.size * (1.6 + pulse * 2.2); + g.globalAlpha = .18 + pulse * .5; + g.fillStyle = s.warm ? "#ffe2b8" : "#dbe8ff"; + g.fillRect(x - len, y - .5, len * 2, 1); + g.fillRect(x - .5, y - len, 1, len * 2); + g.beginPath(); g.arc(x, y, s.size * .7, 0, TAU); g.fill(); } - else if (variant === 1) { - for (const [xx, hh] of [[-18, 34], [15, 45]]) { - polygon(g, [[xx - 7, 6], [xx + 7, 1], [xx + 7, -hh], [xx - 7, -hh + 5]], C.surface, C.edge); - polygon(g, [[xx - 10, -hh + 4], [xx, -hh - 2], [xx + 10, -hh - 6], [xx + 2, -hh]], C.muted, C.edge); - } - g.strokeStyle = C.edge; - g.lineWidth = 5; - g.beginPath(); - g.arc(-1, -10, 17, Math.PI, 0); - g.stroke(); + g.restore(); + // 3. World layer: nebula regions, chart ticks, scenic routes. + const z = camera.zoom, ox = W / 2 - camera.centerX * z, oy = H / 2 - camera.centerY * z; + const nebula = scenery.nebula(points); + g.save(); + g.translate(ox, oy); + g.scale(z, z); + g.imageSmoothingEnabled = true; + g.globalAlpha = .95; + g.drawImage(nebula, 0, 0, nebula.width * 8, nebula.height * 8); + g.globalAlpha = 1; + const left = -ox / z, top = -oy / z, right = left + W / z, bottom = top + H / z, step = z < .12 ? 1024 : 512; + g.fillStyle = "rgba(160,190,220,.16)"; + const tick = 4 / z, thin = 1 / z; + for (let x = Math.floor(left / step) * step; x <= right; x += step) + for (let y = Math.floor(top / step) * step; y <= bottom; y += step) { g.fillRect(x - tick, y - thin / 2, tick * 2, thin); g.fillRect(x - thin / 2, y - tick, thin, tick * 2); } + const routes = scenery.routes(points); + g.lineCap = "round"; + for (const r of routes) { + if (Math.max(r.ax, r.bx, r.cx) < left - 200 || Math.min(r.ax, r.bx, r.cx) > right + 200 || Math.max(r.ay, r.by, r.cy) < top - 200 || Math.min(r.ay, r.by, r.cy) > bottom + 200) continue; + g.beginPath(); g.moveTo(r.ax, r.ay); g.quadraticCurveTo(r.cx, r.cy, r.bx, r.by); + g.strokeStyle = "rgba(127,231,220,.07)"; g.lineWidth = 7 / z; g.stroke(); + g.setLineDash([1.5 / z, 9 / z]); + g.lineDashOffset = animated ? -t * 16 / z : 0; + g.strokeStyle = "rgba(196,240,234,.5)"; g.lineWidth = 1.6 / z; g.stroke(); + g.setLineDash([]); } - else - for (let i = 0; i < 3; i++) { - g.beginPath(); - g.ellipse(0, -i * 9, 28 - i * 6, 11 - i * 2, 0, 0, Math.PI * 2); - g.fillStyle = i % 2 ? C.surface : C.muted; - g.fill(); - g.strokeStyle = C.edge; - g.stroke(); - } g.restore(); -} -/** Original vector scenery. No textures, tile cache, physics or cross-repo graph. */ -export function paintAtlas(g: CanvasRenderingContext2D, points: Landmark[], camera: Camera, size: Size, statuses: Map, selected: string | null, hover: string | null, lit: ReadonlySet = new Set()) { - g.fillStyle = C.bg; - g.fillRect(0, 0, size.width, size.height); + // Travelling route lights (decorative motion only). g.save(); - g.translate(size.width / 2 - camera.centerX * camera.zoom, size.height / 2 - camera.centerY * camera.zoom); - g.scale(camera.zoom, camera.zoom); - for (let i = 0; i < 14; i++) { - const x = 170 + (i % 5) * 1720, y = 340 + Math.floor(i / 5) * 1830, skew = (i % 3) * 140; - polygon(g, [[x, y], [x + 1100, y - 180 - skew], [x + 1510, y + 340], [x + 1050, y + 640], [x - 120, y + 470]], C.surface); - g.beginPath(); - g.moveTo(x - 120, y + 470); - g.bezierCurveTo(x + 350, y + 680, x + 1100, y + 570, x + 1510, y + 340); - g.strokeStyle = C.border; - g.globalAlpha = .35; - g.lineWidth = 2 / camera.zoom; - g.stroke(); - g.globalAlpha = 1; + g.globalCompositeOperation = "lighter"; + const spark = scenery.glow(VERIFIED); + for (const r of routes) { + const u = mod((animated ? t * 90 / r.length : 0) + (r.seed % 997) / 997, 1), p = project(routePoint(r, u), camera, size); + if (p.x < -20 || p.y < -20 || p.x > W + 20 || p.y > H + 20) continue; + const s = 14; + g.globalAlpha = .75 * Math.sin(u * Math.PI); + g.drawImage(spark, p.x - s / 2, p.y - s / 2, s, s); } - g.beginPath(); - g.moveTo(550, 1200); - g.bezierCurveTo(2000, 2200, 2350, 400, 4100, 1200); - g.bezierCurveTo(6000, 2300, 3700, 3700, 6700, 4900); - g.setLineDash([9 / camera.zoom, 12 / camera.zoom]); - g.strokeStyle = C.border; - g.lineWidth = 1.5 / camera.zoom; - g.stroke(); - g.setLineDash([]); g.restore(); - const groups = visibleGroups(points, camera, size, selected), targets: HitTarget[] = [], labels: { - group: typeof groups[number]; - rect: Rect; - text: string; - }[] = [], accepted: Rect[] = []; - const maxLabels = size.width < 600 ? 10 : 24; + // Occasional shooting star, screen space, ambient only. + if (animated) { + const cycle = 9, k = mod(t, cycle) / 1.1, n = Math.floor(t / cycle); + if (k < 1) { + const sx = (hash("meteor" + n) % 1000) / 1000 * W, sy = (hash("meteor-y" + n) % 1000) / 1000 * H * .5, dx = 260, dy = 110; + const hx = sx + dx * k, hy = sy + dy * k, trail = g.createLinearGradient(hx - dx * .35, hy - dy * .35, hx, hy); + trail.addColorStop(0, "rgba(220,235,255,0)"); trail.addColorStop(1, `rgba(220,235,255,${.7 * Math.sin(k * Math.PI)})`); + g.strokeStyle = trail; g.lineWidth = 1.4; + g.beginPath(); g.moveTo(hx - dx * .35, hy - dy * .35); g.lineTo(hx, hy); g.stroke(); + } + } + // Vignette keeps attention on the chart center. + const vignette = g.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .35, W / 2, H / 2, Math.hypot(W, H) * .62); + vignette.addColorStop(0, "rgba(3,5,10,0)"); vignette.addColorStop(1, "rgba(3,5,10,.62)"); + g.fillStyle = vignette; g.beginPath(); g.rect(0, 0, W, H); g.fill(); + // 4. Landmarks. + const groups = visibleGroups(points, camera, size, selected), targets: HitTarget[] = []; + const D = planetSize(z), bucket = Math.max(16, Math.round(D * ratio * 1.18 / 8) * 8); + const isSelected = (gr: ProjectedLandmark) => gr.keys.length === 1 && gr.keys[0] === selected; + const isHover = (gr: ProjectedLandmark) => !!hover && gr.keys.length === 1 && gr.keys[0] === hover; + // Label layout first so editorial region names can avoid landmark labels. + const labels: { group: ProjectedLandmark; rect: Rect; text: string; below: boolean }[] = [], accepted: Rect[] = []; + const maxLabels = W < 600 ? 10 : 24; const byPriority = [...groups].sort((a, b) => Number(b.keys.includes(selected ?? "")) - Number(a.keys.includes(selected ?? "")) || Number(b.keys.includes(hover ?? "")) - Number(a.keys.includes(hover ?? ""))); - for (const group of groups) { - const active = group.keys.length === 1 && group.keys[0] === selected, cached = group.landmark && statuses.get(group.landmark.repoKey)?.state === "cached", extent = camera.zoom < .12 ? 36 : camera.zoom > .6 ? 72 : 54; - const { x, y } = group; - if (group.keys.length > 1) { - g.fillStyle = C.surface; - g.strokeStyle = C.border; - g.lineWidth = 2; - g.beginPath(); - g.roundRect(x - 27, y - 26, 54, 52, 8); - g.fill(); - g.stroke(); - g.fillStyle = C.text; - g.font = '600 14px ui-monospace,monospace'; - g.textAlign = "center"; - g.textBaseline = "middle"; - g.fillText(String(group.keys.length), x, y); + const discs = groups.map(group => ({ group, x: group.x, y: group.y, r: group.keys.length > 1 ? 26 : D * .42 })); + for (const group of byPriority) { + if (labels.length >= maxLabels && !group.keys.includes(selected ?? "")) continue; + const active = group.keys.includes(selected ?? ""), full = group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`; + let text = full; + g.font = `${active ? 600 : 500} ${active ? 13 : 12}px ${MONO}`; + const maxWidth = Math.min(active ? 260 : 180, W - 40); + while (text.length > 1 && g.measureText(text + "…").width > maxWidth) text = text.slice(0, -1); + if (text !== full) text += "…"; + const width = g.measureText(text).width + 30, height = 26, r = (group.keys.length > 1 ? 30 : D / 2) + 10; + const candidates = [{ x: group.x - width / 2, y: group.y + r, width, height }, { x: group.x - width / 2, y: group.y - r - height, width, height }, { x: group.x + r + 4, y: group.y - height / 2, width, height }, { x: group.x - width - r - 4, y: group.y - height / 2, width, height }, + { x: group.x - 14, y: group.y + r - 4, width, height }, { x: group.x - width + 14, y: group.y + r - 4, width, height }, { x: group.x - 14, y: group.y - r - height + 4, width, height }, { x: group.x - width + 14, y: group.y - r - height + 4, width, height }]; + const fits = (c: Rect) => c.x >= 8 && c.x + c.width <= W - 8 && c.y >= 8 && c.y + c.height <= H - 64 && !accepted.some(a => overlaps(c, a)) && !(c.x + c.width > W - 64 && c.y < 230) && !(c.x < 300 && c.y < 62); + // Prefer a spot that leaves every other planet uncovered; only the selected or hovered + // landmark may fall back to label-only spacing, so crowded labels yield instead of hiding planets. + const clear = (c: Rect) => !discs.some(d => d.group !== group && c.x < d.x + d.r && c.x + c.width > d.x - d.r && c.y < d.y + d.r && c.y + c.height > d.y - d.r); + let index = candidates.findIndex(c => fits(c) && clear(c)); + if (index < 0 && (active || group.keys.includes(hover ?? ""))) index = candidates.findIndex(fits); + if (index >= 0) { accepted.push(candidates[index]); labels.push({ group, rect: candidates[index], text, below: index === 0 || index === 4 || index === 5 }); } + } + // Editorial region names: faint chart lettering, never over a landmark label. + if (z > .1) { + g.save(); + g.font = `600 11px ${MONO}`; + g.textAlign = "center"; g.textBaseline = "middle"; + (g as CanvasRenderingContext2D & { letterSpacing?: string }).letterSpacing = "2px"; + for (const region of scenery.regions(points)) { + const p = project({ x: region.x, y: region.y }, camera, size), text = region.name.toUpperCase(), width = g.measureText(text).width + 8; + const rect = { x: p.x - width / 2, y: p.y - D / 2 - 64, width, height: 18 }; + if (rect.x < 8 || rect.x + rect.width > W - 64 || rect.y < 8 || rect.y > H - 70 || accepted.some(a => overlaps(rect, a))) continue; + accepted.push(rect); + g.fillStyle = rgba(region.color, .95); + g.globalAlpha = .9; + g.fillText(text, p.x, rect.y + 9); } - else if (cached) - sculpture(g, x, y, extent, hash(group.keys[0]) % 3); - else { - g.strokeStyle = C.border; - g.lineWidth = 2; - g.setLineDash([5, 4]); - g.beginPath(); - g.ellipse(x, y, extent * .5, extent * .32, 0, 0, Math.PI * 2); - g.stroke(); - g.setLineDash([]); - g.fillStyle = C.surface; - g.fillRect(x - 10, y - 14, 20, 25); - g.fillStyle = C.text; - g.font = '500 18px ui-monospace,monospace'; - g.textBaseline = "middle"; - g.textAlign = "center"; - g.fillText("?", x, y + 5); + g.restore(); + } + for (const group of groups) { + const { x, y } = group, key = group.keys[0], single = group.keys.length === 1, active = isSelected(group), hovered = isHover(group); + const scale = hovered ? 1 + .16 * f.hoverMix : 1, d = D * scale, status = single ? statuses.get(key) : undefined, cached = status?.state === "cached"; + if (!single) { + paintCluster(g, scenery, x, y, group.keys.length, t, animated); + } else if (cached) { + const look = planetLook(group.landmark?.style ?? "moonshot"), sprite = scenery.planet(group.landmark?.style ?? "moonshot", hash(key) % 4, bucket), s = d * 2.2; + g.save(); g.globalCompositeOperation = "lighter"; g.globalAlpha = .55 + (animated ? .12 * Math.sin(t * 1.3 + hash(key) % 7) : 0); + const halo = scenery.glow(look.glow); g.drawImage(halo, x - d * 1.2, y - d * 1.2, d * 2.4, d * 2.4); g.restore(); + g.drawImage(sprite, x - s / 2, y - s / 2, s, s); + // A small moon in orbit marks a verified capture. + const a = (animated ? t * .6 : 0) + (hash(key) % 628) / 100, mx = x + Math.cos(a) * d * .82, my = y + Math.sin(a) * d * .3; + if (Math.sin(a) > -.2 || !look.ring) { g.fillStyle = look.light; g.beginPath(); g.arc(mx, my, Math.max(1.5, d * .05), 0, TAU); g.fill(); } + } else { + paintGhost(g, x, y, d, status?.state ?? "unchecked", group.landmark?.style ?? "moonshot", t, animated); } - if (active || group.keys.includes(hover ?? "")) { - g.beginPath(); - g.ellipse(x, y + 8, extent * .67, extent * .42, 0, 0, Math.PI * 2); - g.strokeStyle = C.surface; - g.lineWidth = 6; - g.stroke(); - g.strokeStyle = active ? C.focus : C.text; - g.lineWidth = 2; - g.stroke(); + if (active) paintSelection(g, x, y, single ? d : 60, t, animated); + else if (hovered || (!single && group.keys.includes(hover ?? ""))) { + g.beginPath(); g.arc(x, y, (single ? d : 60) * .72, 0, TAU); + g.strokeStyle = `rgba(255,255,255,${.3 + .4 * f.hoverMix})`; g.lineWidth = 1.5; g.stroke(); } - if (group.keys.some(key => lit.has(key))) { - g.strokeStyle = C.focus; g.fillStyle = C.surface; g.lineWidth = 2; - g.beginPath(); g.arc(x + extent * .42, y - extent * .5, 12, 0, Math.PI * 2); g.fill(); g.stroke(); - g.fillStyle = C.text; g.font = '600 16px ui-monospace,monospace'; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText("✧", x + extent * .42, y - extent * .5); + if (group.keys.some(k => lit.has(k))) { + const lx = x + d * .48, ly = y - d * .5; + g.save(); g.globalCompositeOperation = "lighter"; g.drawImage(scenery.glow(GOLD), lx - 16, ly - 16, 32, 32); g.restore(); + g.fillStyle = "#1a1408"; g.strokeStyle = GOLD; g.lineWidth = 1.5; + g.beginPath(); g.arc(lx, ly, 10, 0, TAU); g.fill(); g.stroke(); + g.fillStyle = GOLD; g.font = `600 13px ${MONO}`; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText("✧", lx, ly + 1); } - targets.push({ keys: group.keys, x, y, rects: [{ x: x - Math.max(22, extent * .65), y: y - Math.max(22, extent * .8), width: Math.max(44, extent * 1.3), height: Math.max(44, extent * 1.15) }] }); + const hit = Math.max(44, (single ? D : 60) * 1.3); + targets.push({ keys: group.keys, x, y, rects: [{ x: x - hit / 2, y: y - hit / 2, width: hit, height: hit }] }); } - for (const group of byPriority) { - if (labels.length >= maxLabels && !group.keys.includes(selected ?? "")) - continue; - let text = group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`; - g.font = `${group.keys.includes(selected ?? "") ? 600 : 500} ${group.keys.includes(selected ?? "") ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`; - const maxWidth = Math.min(group.keys.includes(selected ?? "") ? 260 : 180, size.width - 24); - while (text.length > 1 && g.measureText(text + "…").width > maxWidth) - text = text.slice(0, -1); - if (text !== (group.landmark?.fullName ?? `${group.keys.length} catalog landmarks`)) - text += "…"; - const width = g.measureText(text).width + 16, height = 30; - const candidates = [{ x: group.x - width / 2, y: group.y + 42, width, height }, { x: group.x - width / 2, y: group.y - 76, width, height }, { x: group.x + 48, y: group.y - 16, width, height }, { x: group.x - width - 48, y: group.y - 16, width, height }]; - const rect = candidates.find(r => r.x >= 8 && r.x + r.width <= size.width - 8 && r.y >= 8 && r.y + r.height <= size.height - 64 && !accepted.some(a => overlaps(r, a)) && !(r.x + r.width > size.width - 64 && r.y < 230)); - if (rect) { - accepted.push(rect); - labels.push({ group, rect, text }); - targets.find(t => t.keys === group.keys)?.rects.push(rect); + // 5. Label plates. + for (const { group, rect, text, below } of labels) { + const active = group.keys.includes(selected ?? ""), hovered = group.keys.includes(hover ?? ""), single = group.keys.length === 1; + const cached = single && statuses.get(group.keys[0])?.state === "cached"; + targets.find(tg => tg.keys === group.keys)?.rects.push(rect); + if (below && single) { + g.strokeStyle = active ? "rgba(82,168,255,.7)" : "rgba(200,220,230,.28)"; g.lineWidth = 1; + g.beginPath(); g.moveTo(group.x, group.y + D / 2 + 2); g.lineTo(group.x, rect.y); g.stroke(); } - } - for (const { group, rect, text } of labels) { - const active = group.keys.includes(selected ?? ""); - g.font = `${active ? 600 : 500} ${active ? 14 : 13}px ui-monospace,SFMono-Regular,Consolas,monospace`; - g.fillStyle = active ? C.labelSelectedPlate : C.labelPlate; - g.strokeStyle = active ? C.labelSelectedBorder : C.border; - g.lineWidth = active ? 2 : 1; - g.beginPath(); - g.roundRect(rect.x, rect.y, rect.width, rect.height, 3); + g.font = `${active ? 600 : 500} ${active ? 13 : 12}px ${MONO}`; + g.beginPath(); g.roundRect(rect.x, rect.y, rect.width, rect.height, 6); + g.fillStyle = active ? "#ffffff" : hovered ? "rgba(22,32,48,.95)" : "rgba(9,14,24,.86)"; g.fill(); - g.stroke(); - g.fillStyle = active ? C.labelSelectedText : C.labelText; - g.textAlign = "center"; - g.textBaseline = "middle"; - g.fillText(text, rect.x + rect.width / 2, rect.y + 15); + if (!cached && !active) g.setLineDash([3, 3]); + g.strokeStyle = active ? "#0068d6" : cached ? "rgba(127,231,220,.75)" : hovered ? "rgba(230,236,240,.8)" : "rgba(170,182,196,.6)"; + g.lineWidth = active ? 2 : 1; g.stroke(); g.setLineDash([]); + // Status pip: filled = verified capture, hollow = not confirmed. + const px = rect.x + 11, py = rect.y + rect.height / 2; + g.beginPath(); g.arc(px, py, 3.2, 0, TAU); + if (cached) { g.fillStyle = active ? "#0f766e" : VERIFIED; g.fill(); } + else if (!single) { g.fillStyle = active ? "#171717" : "#c9d3dc"; g.fill(); } + else { g.strokeStyle = active ? "#525252" : "#aab6c2"; g.lineWidth = 1.2; g.stroke(); } + g.fillStyle = active ? "#171717" : INK; + g.textAlign = "center"; g.textBaseline = "middle"; + g.fillText(text, rect.x + 9 + rect.width / 2, rect.y + rect.height / 2 + .5); } return targets; } + +/** Unconfirmed landmark: a dashed, translucent outline world with a "?" glyph. */ +function paintGhost(g: CanvasRenderingContext2D, x: number, y: number, d: number, state: string, style: string, t: number, animated: boolean) { + const look = planetLook(style), r = d / 2; + g.save(); + const fill = g.createRadialGradient(x - r * .3, y - r * .3, 0, x, y, r); + fill.addColorStop(0, rgba(look.glow, .14)); fill.addColorStop(1, rgba(look.glow, .03)); + g.fillStyle = fill; g.beginPath(); g.arc(x, y, r, 0, TAU); g.fill(); + g.setLineDash([3, 4]); + g.lineDashOffset = animated ? -t * 4 : 0; + g.strokeStyle = state === "unavailable" ? "rgba(255,190,110,.8)" : "rgba(214,224,232,.7)"; g.lineWidth = 1.5; + g.beginPath(); g.arc(x, y, r, 0, TAU); g.stroke(); + g.setLineDash([2, 6]); + g.strokeStyle = "rgba(214,224,232,.22)"; g.lineWidth = 1; + g.beginPath(); g.ellipse(x, y, r * 1.55, r * .45, -.35, 0, TAU); g.stroke(); + g.setLineDash([]); + if (state === "loading") { + const a = animated ? t * 5 : 0; + g.strokeStyle = VERIFIED; g.lineWidth = 2; + g.beginPath(); g.arc(x, y, r + 4, a, a + Math.PI * .6); g.stroke(); + } + g.fillStyle = state === "unavailable" ? "#ffcf8a" : INK; + g.font = `500 ${Math.round(Math.max(14, d * .42))}px ${MONO}`; + g.textAlign = "center"; g.textBaseline = "middle"; + g.fillText(state === "miss" ? "×" : state === "unavailable" ? "!" : "?", x, y + 1); + g.restore(); +} + +function paintCluster(g: CanvasRenderingContext2D, scenery: Scenery, x: number, y: number, count: number, t: number, animated: boolean) { + g.save(); + g.globalCompositeOperation = "lighter"; + g.globalAlpha = .7; + g.drawImage(scenery.glow("#8aa8ff"), x - 46, y - 46, 92, 92); + g.globalAlpha = 1; + const dots = Math.min(9, count), spin = animated ? t * .35 : 0; + g.fillStyle = "#dbe6ff"; + for (let i = 0; i < dots; i++) { const a = spin + i / dots * TAU; g.beginPath(); g.arc(x + Math.cos(a) * 30, y + Math.sin(a) * 12, 1.8, 0, TAU); g.fill(); } + g.restore(); + g.beginPath(); g.arc(x, y, 19, 0, TAU); + g.fillStyle = "rgba(10,16,30,.92)"; g.fill(); + g.strokeStyle = "rgba(190,210,255,.75)"; g.lineWidth = 1.5; g.stroke(); + g.fillStyle = INK; g.font = `600 13px ${MONO}`; g.textAlign = "center"; g.textBaseline = "middle"; + g.fillText(String(count), x, y + 1); +} + +/** Targeting reticle plus a soft pulse; a single still ring without motion. */ +function paintSelection(g: CanvasRenderingContext2D, x: number, y: number, d: number, t: number, animated: boolean) { + g.save(); + if (animated) for (let i = 0; i < 2; i++) { + const k = mod(t / 1.8 + i * .5, 1); + g.globalAlpha = (1 - k) * .55; + g.strokeStyle = SELECT; g.lineWidth = 2 * (1 - k) + .5; + g.beginPath(); g.arc(x, y, d * (.6 + k * .75), 0, TAU); g.stroke(); + } + g.globalAlpha = 1; + g.strokeStyle = SELECT; g.lineWidth = 2; + g.beginPath(); g.arc(x, y, d * .68, 0, TAU); g.stroke(); + const rr = d * .92, rot = animated ? t * .5 : Math.PI / 4, arm = Math.max(6, d * .2); + g.lineWidth = 2; g.strokeStyle = "#cfe6ff"; + for (let i = 0; i < 4; i++) { + const a = rot + i * Math.PI / 2; + g.beginPath(); g.arc(x, y, rr, a - arm / rr, a + arm / rr); g.stroke(); + } + g.restore(); +} diff --git a/src/atlas/scenery.ts b/src/atlas/scenery.ts new file mode 100644 index 0000000..327bb2b --- /dev/null +++ b/src/atlas/scenery.ts @@ -0,0 +1,261 @@ +import type { StyleId } from "../contracts"; +import { SCENE } from "./camera"; +import { hash, type Landmark } from "./model"; + +/** Original procedural Atlas scenery. Everything here is illustration: no + * repository facts, dependency graph or live data is encoded in a pixel. Layers + * are rendered once into small offscreen canvases so an ambient frame is a few + * image blits plus vector strokes. */ + +export interface PlanetLook { + base: string; light: string; dark: string; feature: string; accent: string; glow: string; ring: string | null; +} +/** Planet looks hint at each landmark's suggested art style (palettes after the hero diorama). */ +export const PLANETS: Record = { + moonshot: { base: "#9a9ca1", light: "#eef2ef", dark: "#24262c", feature: "#6f7178", accent: "#7fe7dc", glow: "#8ff3ea", ring: null }, + factorio: { base: "#9a8452", light: "#e4c98c", dark: "#2a2216", feature: "#6b5a36", accent: "#f2b13b", glow: "#ffcf6e", ring: "#c9a95e" }, + "red-alert": { base: "#5e7c3e", light: "#b6cf86", dark: "#17231a", feature: "#2f5e6e", accent: "#ff5a43", glow: "#ffd27a", ring: null }, + "red-alert-2": { base: "#1d6d92", light: "#c3e6f0", dark: "#06132a", feature: "#6ea86a", accent: "#39d0ff", glow: "#6fe3ff", ring: "#39d0ff" }, + civilization: { base: "#3c83b2", light: "#f4f0d8", dark: "#0d1730", feature: "#8fae5a", accent: "#e0b04a", glow: "#ffe1a0", ring: null }, +}; +export const planetLook = (style: string): PlanetLook => PLANETS[style as StyleId] ?? PLANETS.moonshot; + +/** Nebula tints for editorial regions (category names only; not a data claim). */ +const NEBULA = ["#6a4be0", "#1fa3a8", "#c2448a", "#3667e0", "#d0803a", "#3aa866", "#9a4fd6", "#2b8fd6"]; +export const regionColor = (category: string) => NEBULA[hash(category.toLowerCase()) % NEBULA.length]; + +const rng = (seed: number) => () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +const canvas = (w: number, h: number) => { const c = document.createElement("canvas"); c.width = Math.max(1, Math.ceil(w)); c.height = Math.max(1, Math.ceil(h)); return c; }; +const rgba = (hex: string, a: number) => { const n = parseInt(hex.slice(1), 16); return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`; }; +export { rgba }; + +export interface Route { ax: number; ay: number; cx: number; cy: number; bx: number; by: number; length: number; seed: number } +export interface Region { name: string; color: string; x: number; y: number; count: number } +export interface Twinkle { x: number; y: number; size: number; speed: number; phase: number; warm: boolean } + +export const STAR_TILE = 512; + +/** Greedy nearest-neighbour scenic chain. Purely decorative: it never reads + * dependencies, stars or response order beyond the stable layout positions. */ +export function scenicRoutes(points: readonly Landmark[]): Route[] { + const pool = points.slice(0, 120), routes: Route[] = []; + if (pool.length < 2) return routes; + const left = new Set(pool.map((_, i) => i)); + let at = pool.reduce((best, p, i) => (p.x + p.y < pool[best].x + pool[best].y ? i : best), 0); + left.delete(at); + while (left.size) { + let next = -1, distance = Infinity; + for (const i of left) { const d = Math.hypot(pool[i].x - pool[at].x, pool[i].y - pool[at].y); if (d < distance) { distance = d; next = i; } } + left.delete(next); + const a = pool[at], b = pool[next]; + if (distance < 1900) { + const seed = hash(a.repoKey + ">" + b.repoKey), bend = ((seed % 1000) / 1000 - .5) * .5, mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2; + const cx = mx - (b.y - a.y) * bend, cy = my + (b.x - a.x) * bend; + routes.push({ ax: a.x, ay: a.y, cx, cy, bx: b.x, by: b.y, length: distance * (1 + Math.abs(bend) * .6), seed }); + } + at = next; + } + return routes; +} +export const routePoint = (r: Route, u: number) => { const v = 1 - u; return { x: v * v * r.ax + 2 * v * u * r.cx + u * u * r.bx, y: v * v * r.ay + 2 * v * u * r.cy + u * u * r.by }; }; + +/** Editorial regions with a compact footprint get a faint name in the sky. */ +export function editorialRegions(points: readonly Landmark[]): Region[] { + const groups = new Map(); + for (const p of points.slice(0, 2000)) { const list = groups.get(p.category) ?? []; list.push(p); groups.set(p.category, list); } + const regions: Region[] = []; + for (const [name, list] of groups) { + const x = list.reduce((n, p) => n + p.x, 0) / list.length, y = list.reduce((n, p) => n + p.y, 0) / list.length; + if (list.length > 40 || list.some(p => Math.hypot(p.x - x, p.y - y) > 1500)) continue; + regions.push({ name, color: regionColor(name), x, y: Math.min(...list.map(p => p.y)), count: list.length }); + } + return regions; +} + +export interface Scenery { + stars(ratio: number): { far: CanvasPattern | null; near: CanvasPattern | null; twinkles: Twinkle[] }; + nebula(points: readonly Landmark[]): HTMLCanvasElement; + routes(points: readonly Landmark[]): Route[]; + regions(points: readonly Landmark[]): Region[]; + planet(style: string, variant: number, px: number): HTMLCanvasElement; + glow(color: string): HTMLCanvasElement; + dispose(): void; +} + +export function createScenery(g: CanvasRenderingContext2D): Scenery { + let starRatio = 0, starLayers: ReturnType | null = null; + let nebulaFor: readonly Landmark[] | null = null, nebulaCanvas: HTMLCanvasElement | null = null; + let routesFor: readonly Landmark[] | null = null, routeList: Route[] = []; + let regionsFor: readonly Landmark[] | null = null, regionList: Region[] = []; + const sprites = new Map(), glows = new Map(); + return { + stars(ratio) { + if (starLayers && starRatio === ratio) return starLayers; + starRatio = ratio; + const tile = (count: number, seed: number, bright: number) => { + const c = canvas(STAR_TILE * ratio, STAR_TILE * ratio), x = c.getContext("2d"), r = rng(seed); + if (!x) return null; + for (let i = 0; i < count; i++) { + const size = (.35 + r() ** 3 * bright) * ratio, a = .18 + r() * .62, tint = r(); + x.fillStyle = tint > .9 ? `rgba(255,214,170,${a})` : tint > .75 ? `rgba(170,205,255,${a})` : `rgba(235,240,255,${a})`; + x.beginPath(); x.arc(r() * c.width, r() * c.height, size, 0, Math.PI * 2); x.fill(); + } + return g.createPattern(c, "repeat"); + }; + const r = rng(77), twinkles: Twinkle[] = Array.from({ length: 22 }, () => ({ x: r() * 1024, y: r() * 1024, size: .8 + r() * 1.2, speed: .6 + r() * 1.6, phase: r() * Math.PI * 2, warm: r() > .7 })); + starLayers = { far: tile(340, 11, 1.1), near: tile(70, 29, 1.9), twinkles }; + return starLayers; + }, + nebula(points) { + if (nebulaCanvas && nebulaFor === points) return nebulaCanvas; + nebulaFor = points; + const k = 1 / 8, c = nebulaCanvas ?? canvas(SCENE.right * k, SCENE.bottom * k), x = c.getContext("2d"); + nebulaCanvas = c; + if (!x) return c; + x.setTransform(1, 0, 0, 1, 0, 0); + x.clearRect(0, 0, c.width, c.height); + x.globalCompositeOperation = "lighter"; + const r = rng(4242); + // Deep background dust lanes: decorative, independent of the catalog. + for (let i = 0; i < 9; i++) { + const cx = r() * c.width, cy = r() * c.height, rad = 120 + r() * 180, grad = x.createRadialGradient(cx, cy, 0, cx, cy, rad); + grad.addColorStop(0, rgba(i % 2 ? "#2a3a7a" : "#3b2a6a", .16)); grad.addColorStop(1, "rgba(0,0,0,0)"); + x.fillStyle = grad; x.beginPath(); x.ellipse(cx, cy, rad, rad * (.45 + r() * .4), r() * Math.PI, 0, Math.PI * 2); x.fill(); + } + for (const p of points.slice(0, 600)) { + const h = hash(p.repoKey), color = regionColor(p.category), px = p.x * k, py = p.y * k; + for (let j = 0; j < 3; j++) { + const ox = ((h >> (j * 5)) % 60 - 30) * (j + 1) * .9, oy = ((h >> (j * 7 + 3)) % 50 - 25) * (j + 1) * .7, rad = (64 + (h >> (j * 3)) % 48) * (1 - j * .15); + const grad = x.createRadialGradient(px + ox, py + oy, 0, px + ox, py + oy, rad); + grad.addColorStop(0, rgba(color, j ? .16 : .3)); grad.addColorStop(.55, rgba(color, j ? .07 : .12)); grad.addColorStop(1, rgba(color, 0)); + x.fillStyle = grad; + x.beginPath(); x.ellipse(px + ox, py + oy, rad * 1.35, rad * .8, (h % 314) / 100, 0, Math.PI * 2); x.fill(); + } + } + x.globalCompositeOperation = "source-over"; + // Fine dust specks inside the clouds. + for (const p of points.slice(0, 300)) { + const local = rng(hash(p.repoKey + ":dust")); + x.fillStyle = rgba("#e6ecff", .35); + for (let i = 0; i < 14; i++) { const a = local() * Math.PI * 2, d = local() ** .6 * 70; x.fillRect(p.x * k + Math.cos(a) * d, p.y * k + Math.sin(a) * d * .7, .6, .6); } + } + return c; + }, + routes(points) { if (routesFor !== points) { routesFor = points; routeList = scenicRoutes(points); } return routeList; }, + regions(points) { if (regionsFor !== points) { regionsFor = points; regionList = editorialRegions(points); } return regionList; }, + planet(style, variant, px) { + const key = `${style}:${variant}:${px}`; + let c = sprites.get(key); + if (!c) { + if (sprites.size > 96) sprites.clear(); + c = paintPlanet(style, variant, px); + sprites.set(key, c); + } + return c; + }, + glow(color) { + let c = glows.get(color); + if (!c) { + c = canvas(64, 64); + const x = c.getContext("2d"); + if (x) { const grad = x.createRadialGradient(32, 32, 0, 32, 32, 32); grad.addColorStop(0, rgba(color, .9)); grad.addColorStop(.25, rgba(color, .35)); grad.addColorStop(1, rgba(color, 0)); x.fillStyle = grad; x.beginPath(); x.arc(32, 32, 32, 0, Math.PI * 2); x.fill(); } + glows.set(color, c); + } + return c; + }, + dispose() { sprites.clear(); glows.clear(); nebulaCanvas = null; starLayers = null; }, + }; +} + +/** A verified landmark: a small lit world with a settlement on its horizon. + * The sprite is 2.2× the planet diameter so halo and ring fit. */ +function paintPlanet(style: string, variant: number, px: number) { + const look = planetLook(style), size = px * 2.2, c = canvas(size, size), x = c.getContext("2d"); + if (!x) return c; + const r = px / 2, cx = size / 2, cy = size / 2, rand = rng(hash(style) + variant * 977); + // Atmosphere halo. + const halo = x.createRadialGradient(cx, cy, r * .8, cx, cy, r * 1.75); + halo.addColorStop(0, rgba(look.glow, .42)); halo.addColorStop(.35, rgba(look.glow, .12)); halo.addColorStop(1, rgba(look.glow, 0)); + x.fillStyle = halo; x.beginPath(); x.arc(cx, cy, r * 1.75, 0, Math.PI * 2); x.fill(); + const tilt = -.38 + variant * .05; + const ring = (front: boolean) => { + if (!look.ring) return; + x.save(); x.translate(cx, cy); x.rotate(tilt); + x.beginPath(); x.ellipse(0, 0, r * 1.62, r * .42, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2); + x.strokeStyle = rgba(look.ring, front ? .85 : .45); x.lineWidth = Math.max(1, r * .14); x.stroke(); + x.beginPath(); x.ellipse(0, 0, r * 1.38, r * .34, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2); + x.strokeStyle = rgba(look.ring, front ? .5 : .25); x.lineWidth = Math.max(.6, r * .05); x.stroke(); + x.restore(); + }; + ring(false); + // Sphere body. + x.save(); + x.beginPath(); x.arc(cx, cy, r, 0, Math.PI * 2); x.clip(); + x.fillStyle = look.base; x.fillRect(cx - r, cy - r, px, px); + if (style === "factorio") { + for (let i = 0; i < 7; i++) { x.fillStyle = rgba(i % 2 ? look.feature : look.light, .18 + rand() * .2); x.fillRect(cx - r, cy - r + (i / 7) * px + rand() * 3, px, px / 9 + rand() * px / 14); } + x.fillStyle = rgba(look.accent, .55); x.beginPath(); x.ellipse(cx + r * .25, cy + r * .2, r * .22, r * .11, 0, 0, Math.PI * 2); x.fill(); + } else if (style === "moonshot") { + for (let i = 0; i < 9; i++) { const a = rand() * Math.PI * 2, d = rand() * r * .8, s = r * (.08 + rand() * .2), ex = cx + Math.cos(a) * d, ey = cy + Math.sin(a) * d; x.fillStyle = rgba(look.feature, .75); x.beginPath(); x.arc(ex, ey, s, 0, Math.PI * 2); x.fill(); x.strokeStyle = rgba(look.light, .35); x.lineWidth = Math.max(.6, s * .25); x.beginPath(); x.arc(ex - s * .15, ey - s * .15, s, Math.PI * .9, Math.PI * 1.8); x.stroke(); } + } else { + // Continents (red-alert: seas on olive land; RA2/civ: land on ocean). + for (let i = 0; i < 6; i++) { + const a = rand() * Math.PI * 2, d = rand() * r * .75, ex = cx + Math.cos(a) * d, ey = cy + Math.sin(a) * d; + x.fillStyle = rgba(look.feature, .92); x.beginPath(); + for (let k = 0; k < 7; k++) { const t = (k / 7) * Math.PI * 2, rr = r * (.14 + rand() * .2); k ? x.lineTo(ex + Math.cos(t) * rr * 1.3, ey + Math.sin(t) * rr) : x.moveTo(ex + Math.cos(t) * rr * 1.3, ey + Math.sin(t) * rr); } + x.closePath(); x.fill(); + } + if (style === "civilization") { + x.fillStyle = rgba("#cdb65e", .75); + for (let i = 0; i < 4; i++) { x.beginPath(); x.arc(cx + (rand() - .5) * r * 1.2, cy + (rand() - .5) * r * 1.2, r * (.06 + rand() * .1), 0, Math.PI * 2); x.fill(); } + x.strokeStyle = rgba("#ffffff", .5); x.lineWidth = Math.max(.6, r * .07); + for (let i = 0; i < 3; i++) { x.beginPath(); const yy = cy - r * .6 + i * r * .55 + rand() * r * .2; x.moveTo(cx - r, yy); x.bezierCurveTo(cx - r * .3, yy - r * .2, cx + r * .2, yy + r * .2, cx + r, yy - r * .05); x.stroke(); } + } + } + // Day/night terminator and rim light. + const shade = x.createRadialGradient(cx - r * .45, cy - r * .5, r * .1, cx - r * .1, cy - r * .1, r * 1.45); + shade.addColorStop(0, rgba("#ffffff", .32)); shade.addColorStop(.45, "rgba(0,0,0,0)"); shade.addColorStop(.8, rgba(look.dark, .55)); shade.addColorStop(1, rgba(look.dark, .92)); + x.fillStyle = shade; x.fillRect(cx - r, cy - r, px, px); + // Settlement lights on the night side. + x.fillStyle = look.accent; + for (let i = 0; i < 8; i++) { const a = Math.PI * (.05 + rand() * .55), d = r * (.45 + rand() * .45); x.globalAlpha = .55 + rand() * .45; x.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, Math.max(1, r * .06), Math.max(1, r * .06)); } + x.globalAlpha = 1; + x.restore(); + x.strokeStyle = rgba(look.glow, .75); x.lineWidth = Math.max(1, r * .06); + x.beginPath(); x.arc(cx, cy, r - x.lineWidth / 2, Math.PI * 1.02, Math.PI * 1.72); x.stroke(); + settlement(x, style, cx, cy, r, look); + ring(true); + return c; +} + +/** Tiny original silhouettes on the top horizon hint at the world's perspective. */ +function settlement(x: CanvasRenderingContext2D, style: string, cx: number, cy: number, r: number, look: PlanetLook) { + if (r < 14) return; + x.save(); + x.translate(cx, cy); x.rotate(-.42); x.translate(0, -r + r * .04); + const u = r / 16; + x.fillStyle = look.light; x.strokeStyle = look.light; x.lineWidth = Math.max(1, u * .7); + const rect = (px: number, w: number, h: number) => x.fillRect(px * u, -h * u, w * u, h * u + u); + if (style === "moonshot") { + x.beginPath(); x.arc(-3 * u, 0, 3.4 * u, Math.PI, 0); x.fill(); + x.beginPath(); x.arc(3.5 * u, 0, 2.2 * u, Math.PI, 0); x.fill(); + rect(6.5, 1.2, 7); x.fillStyle = look.accent; x.fillRect(6.4 * u, -8 * u, 1.4 * u, 1.4 * u); + } else if (style === "factorio") { + rect(-6, 6, 3.5); rect(-4.5, 1.4, 7.5); rect(-1.6, 1.4, 6); rect(1.5, 4.5, 2.5); + x.fillStyle = rgba("#d8cfbf", .55); x.beginPath(); x.arc(-3.8 * u, -9.5 * u, 1.8 * u, 0, Math.PI * 2); x.arc(-2.5 * u, -11.5 * u, 1.3 * u, 0, Math.PI * 2); x.fill(); + } else if (style === "red-alert") { + rect(-5, 5, 3); rect(1, 1, 6); + x.beginPath(); x.ellipse(1.5 * u, -7 * u, 3 * u, 1.4 * u, -.4, 0, Math.PI * 2); x.stroke(); + x.fillStyle = look.accent; x.fillRect(-1 * u, -4.5 * u, 1.3 * u, 1.3 * u); + } else if (style === "red-alert-2") { + rect(-5.5, 3.6, 4); rect(-.5, 1.2, 8.5); + x.beginPath(); x.arc(.1 * u, -9.6 * u, 1.8 * u, 0, Math.PI * 2); x.fill(); + x.fillStyle = look.accent; x.beginPath(); x.arc(.1 * u, -9.6 * u, .9 * u, 0, Math.PI * 2); x.fill(); + rect(3, 3, 3); + } else { + x.beginPath(); x.moveTo(-6 * u, 0); x.lineTo(-6 * u, -3 * u); x.lineTo(-4 * u, -5 * u); x.lineTo(-2 * u, -3 * u); x.lineTo(-2 * u, 0); x.fill(); + x.beginPath(); x.moveTo(0, u); x.lineTo(0, -5 * u); x.lineTo(2.5 * u, -8 * u); x.lineTo(5 * u, -5 * u); x.lineTo(5 * u, u); x.fill(); + x.fillStyle = look.accent; x.fillRect(1.9 * u, -4 * u, 1.2 * u, 1.6 * u); + } + x.restore(); +} From 83730bae66f47f2b468a4d20f13215485522b576 Mon Sep 17 00:00:00 2001 From: ubuntu03 Date: Fri, 6 Nov 2026 17:22:28 -0800 Subject: [PATCH 08/17] feat(atlas): observatory chrome, list swatches, legend and field guide Co-Authored-By: Claude Opus 5.5 --- src/atlas/AtlasPage.tsx | 7 +- src/atlas/atlas.css | 140 +++++++++++++++++++++++++++------------- 2 files changed, 100 insertions(+), 47 deletions(-) diff --git a/src/atlas/AtlasPage.tsx b/src/atlas/AtlasPage.tsx index 4646166..9275b24 100644 --- a/src/atlas/AtlasPage.tsx +++ b/src/atlas/AtlasPage.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowUpRight } from "lucide-react"; import type { FeaturedProject } from "../contracts"; import { getCachedWorld, getGallery } from "../client/api"; import { Brand, External } from "../components/shared"; @@ -158,7 +159,7 @@ export function AtlasPage() { save(); } }, [facts, selected, landmarks, save]); return
-

Repository Atlas

Mission Control
+

Repository Atlas

An observatory of open sourceMission Control
{progress.durableCountKnown ? `${counts.saved} saved Exploration stars` : "Saved Exploration star count unknown"} · {counts.session} session Exploration stars — not saved{resetConfirm &&

Clear only this browser’s exploration flags and stars? Atlas positions, public source caches and server budgets are preserved.

}{resetError &&

{resetError}

}{progress.warning &&

{progress.warning}

}

Click a landmark to enter its cached world, or inspect a repository first. This atlas shows its catalog and your cached survey entries.

{catalog === null ? "Loading catalog" : `${landmarks.length} listed landmarks · ${[...statuses.values()].filter(s => s.state === "cached").length} captures checked this visit`} · No full GitHub map or complete history is claimed.

{catalogError &&

{catalogError}

} @@ -169,7 +170,7 @@ export function AtlasPage() {
{ if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(e.key)) return; const buttons = Array.from(e.currentTarget.querySelectorAll("button")), index = buttons.indexOf(document.activeElement as HTMLButtonElement); if (index < 0) return; e.preventDefault(); const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : Math.max(0, Math.min(buttons.length - 1, index + (e.key === "ArrowDown" ? 1 : -1))); buttons[next]?.focus(); }}> - {rows.map((p, i) => )} + {rows.map((p, i) =>