diff --git a/app.js b/app.js new file mode 100644 index 0000000..b356f84 --- /dev/null +++ b/app.js @@ -0,0 +1,206 @@ +const FONT_CSS = "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700"; +const BASE_DPI = 300; + +const ART = { + front: { w: 1200, h: 340 }, + back: { w: 3300, h: 2100 } +}; + +// [x, y, r, colorIndex] — colorIndex 0 indigo, 1 cyan, 2 core, 3 anomaly ring +const NODES = [ + [430, 380, 30, 0], [1080, 210, 24, 1], [1760, 330, 30, 0], [2480, 250, 26, 3], + [2890, 720, 30, 0], [2200, 900, 36, 1], [1500, 760, 46, 2], [830, 940, 30, 1], + [400, 1180, 24, 0], [1980, 1300, 30, 0], [1180, 1320, 26, 1], [2760, 1230, 22, 1] +]; + +const EDGES = [ + [0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7], [7, 8], [8, 0], + [1, 6], [2, 6], [6, 9], [9, 5], [7, 10], [10, 6], [10, 9], [4, 11], [11, 9], [0, 7] +]; + +const PALETTE = { indigo: "#4f46e5", cyan: "#0ea5e9" }; + +const el = (id) => document.getElementById(id); +const status = el("status"); + +const setStatus = (msg, cls = "") => { + status.textContent = msg; + status.className = `status ${cls}`; +}; + +const b64 = (buf) => { + const bytes = new Uint8Array(buf); + let bin = ""; + for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(bin); +}; + +let fontFacePromise = null; + +const loadEmbeddedFont = () => { + fontFacePromise ??= (async () => { + const css = await fetch(FONT_CSS).then((r) => r.text()); + const blocks = css.split("@font-face").filter((b) => b.includes("U+0000-00FF")); + const faces = await Promise.all( + blocks.map(async (block) => { + const weight = block.match(/font-weight:\s*(\d+)/)?.[1] || "400"; + const url = block.match(/url\((https:\/\/[^)]+\.woff2)\)/)?.[1]; + if (!url) return ""; + const data = b64(await fetch(url).then((r) => r.arrayBuffer())); + return `@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:${weight};src:url(data:font/woff2;base64,${data}) format('woff2');}`; + }) + ); + return faces.join(""); + })().catch(() => ""); + return fontFacePromise; +}; + +const theme = (variant) => + variant === "dark" + ? { ink: "#ffffff", edge: "#ffffff", edgeOpacity: ".30", word: "#ffffff", sub: "#8ab4f8" } + : { ink: "#15151b", edge: "#15151b", edgeOpacity: ".32", word: "#15151b", sub: "#4f46e5" }; + +const defs = (fontCss) => ` + + + + + + + `; + +const logoMark = (t) => ` + + + + + + + + + `; + +const constellation = (t) => { + const lines = EDGES.map(([a, b]) => { + const [x1, y1] = NODES[a]; + const [x2, y2] = NODES[b]; + return ``; + }).join(""); + + const dots = NODES.map(([x, y, r, c]) => { + if (c === 3) return ``; + if (c === 2) return ``; + return ``; + }).join(""); + + return `${lines}${dots}`; +}; + +const wrap = (w, h, body, fontCss, guides) => ` +${defs(fontCss)} +${guides ? `` : ""} +${body} +`; + +const buildFront = ({ variant, fontCss, guides }) => { + const t = theme(variant); + const { w, h } = ART.front; + const body = ` + ${logoMark(t)} + apophenia.news`; + return wrap(w, h, body, fontCss, guides); +}; + +const buildBack = ({ variant, fontCss, guides }) => { + const t = theme(variant); + const { w, h } = ART.back; + const body = ` + ${constellation(t)} + finding patterns in the noise + apophenia.news`; + return wrap(w, h, body, fontCss, guides); +}; + +const BUILDERS = { front: buildFront, back: buildBack }; + +const state = () => ({ + variant: el("variant").value, + guides: el("guides").checked, + dpi: +el("dpi").value +}); + +const render = () => { + const { variant, guides } = state(); + for (const key of Object.keys(BUILDERS)) { + const stage = el(`stage-${key}`); + stage.dataset.bg = variant; + stage.innerHTML = BUILDERS[key]({ variant, fontCss: "", guides }); + } +}; + +const svgToBlob = (svg) => new Blob([svg], { type: "image/svg+xml;charset=utf-8" }); + +const rasterize = async (svg, w, h, scale) => { + const url = URL.createObjectURL(svgToBlob(svg)); + try { + const img = new Image(); + img.decoding = "sync"; + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = () => reject(new Error("SVG rasterization failed")); + img.src = url; + }); + const canvas = document.createElement("canvas"); + canvas.width = Math.round(w * scale); + canvas.height = Math.round(h * scale); + const ctx = canvas.getContext("2d"); + ctx.imageSmoothingQuality = "high"; + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + return await new Promise((resolve, reject) => + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("Canvas export failed"))), "image/png") + ); + } finally { + URL.revokeObjectURL(url); + } +}; + +const saveBlob = (blob, name) => { + const url = URL.createObjectURL(blob); + const a = Object.assign(document.createElement("a"), { href: url, download: name }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 2000); +}; + +const download = async (btn) => { + const { dl: key, fmt } = btn.dataset; + const { variant, dpi } = state(); + const { w, h } = ART[key]; + btn.disabled = true; + setStatus(`Building ${key} ${fmt.toUpperCase()}\u2026`); + try { + const fontCss = await loadEmbeddedFont(); + if (!fontCss) setStatus("Font embed unavailable \u2014 text may fall back. Check connection.", "err"); + const svg = BUILDERS[key]({ variant, fontCss, guides: false }); + const stem = `apophenia-${key}-${variant}`; + if (fmt === "svg") saveBlob(svgToBlob(svg), `${stem}.svg`); + else saveBlob(await rasterize(svg, w, h, dpi / BASE_DPI), `${stem}-${dpi}dpi.png`); + if (fontCss) setStatus(`Saved ${stem}.${fmt}`, "ok"); + } catch (err) { + setStatus(err.message || "Export failed. Try a lower DPI.", "err"); + } finally { + btn.disabled = false; + } +}; + +document.querySelectorAll("[data-dl]").forEach((btn) => btn.addEventListener("click", () => download(btn))); +["variant", "guides", "dpi"].forEach((id) => el(id).addEventListener("change", render)); + +render(); +loadEmbeddedFont().then((css) => + setStatus(css ? "Fonts embedded. Ready to export." : "Font embed failed \u2014 exports may use a fallback face.", css ? "ok" : "err") +);