app.js (6402B)
1 /** 2 * Landing runv.club — carrega members.json (só dados públicos) e coloca 3 * pontos clicáveis (links) fora da coluna de texto; brilho ligado à data since. 4 * Pontos no fundo do documento: #starfield é absolute dentro de .page-root (rolam com a página). 5 * Recalculam em resize e quando a altura do .page-root muda (ResizeObserver). 6 * Array vazio: sem estrelas até build_directory.py gerar o JSON a partir de users.json. 7 */ 8 9 function hashUsername(s) { 10 let h = 2166136261; 11 for (let i = 0; i < s.length; i++) { 12 h ^= s.charCodeAt(i); 13 h = Math.imul(h, 16777619); 14 } 15 return h >>> 0; 16 } 17 18 function parseSince(iso) { 19 if (!iso) return 0; 20 const t = Date.parse(iso); 21 return Number.isFinite(t) ? t : 0; 22 } 23 24 function starBrightness(sinceMs) { 25 const now = Date.now(); 26 const age = Math.max(0, now - sinceMs); 27 const halfYear = 180 * 24 * 3600 * 1000; 28 const t = Math.exp(-age / halfYear); 29 return 0.25 + 0.75 * t; 30 } 31 32 function seededPoint(w, h, seed) { 33 const x = (Math.sin(seed * 0.001) * 43758.5453) % 1; 34 const y = (Math.cos(seed * 0.002) * 23421.6789) % 1; 35 const nx = ((x < 0 ? -x : x) * 0.85 + 0.075) * w; 36 const ny = ((y < 0 ? -y : y) * 0.85 + 0.075) * h; 37 return { x: nx, y: ny }; 38 } 39 40 /** Expande o rect em px (viewport) para manter margem em relação ao texto. */ 41 function inflateRect(r, pad) { 42 return { 43 left: r.left - pad, 44 top: r.top - pad, 45 right: r.right + pad, 46 bottom: r.bottom + pad, 47 }; 48 } 49 50 function pointInRect(x, y, rect) { 51 return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; 52 } 53 54 /** 55 * Faixa central de texto (como .wrap) em coordenadas do contentor da página — altura total do documento. 56 */ 57 function documentColumnExcludeRect(hostEl) { 58 const vw = hostEl ? hostEl.offsetWidth : window.innerWidth; 59 const h = hostEl ? hostEl.offsetHeight : window.innerHeight; 60 const rootFs = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; 61 const maxBlock = 46 * rootFs; 62 const pad = Math.min(Math.max(rootFs, vw * 0.04), 1.35 * rootFs); 63 const contentW = Math.min(maxBlock, Math.max(0, vw - 2 * pad)); 64 const left = Math.max(0, (vw - contentW) / 2); 65 return { 66 left, 67 top: 0, 68 right: left + contentW, 69 bottom: h, 70 }; 71 } 72 73 /** 74 * Posição para um ponto: fora da coluna central (área w×h do contentor), com fallback 75 * para faixas laterais ou cantos quando o ecrã é estreito. 76 */ 77 function findStarPosition(w, h, seed, exclude) { 78 const edge = 14; 79 for (let attempt = 0; attempt < 140; attempt++) { 80 const s = seed + attempt * 9973; 81 const { x, y } = seededPoint(w, h, s); 82 const px = Math.max(edge, Math.min(w - edge, x)); 83 const py = Math.max(edge, Math.min(h - edge, y)); 84 if (!pointInRect(px, py, exclude)) return { x: px, y: py }; 85 } 86 87 const spaceLeft = Math.max(0, exclude.left - edge); 88 const spaceRight = Math.max(0, w - exclude.right - edge); 89 const spaceAbove = Math.max(0, exclude.top - edge); 90 const spaceBelow = Math.max(0, h - exclude.bottom - edge); 91 const order = [ 92 [spaceLeft, "left"], 93 [spaceRight, "right"], 94 [spaceAbove, "above"], 95 [spaceBelow, "below"], 96 ].sort((a, b) => b[0] - a[0]); 97 98 const yJitter = edge + ((seed >>> 5) % Math.max(1, h - 2 * edge)); 99 const xJitter = edge + ((seed >>> 9) % Math.max(1, w - 2 * edge)); 100 101 for (const [, side] of order) { 102 if (side === "left" && spaceLeft > 6) 103 return { x: edge + spaceLeft * 0.45, y: yJitter }; 104 if (side === "right" && spaceRight > 6) 105 return { x: exclude.right + spaceRight * 0.55, y: yJitter }; 106 if (side === "above" && spaceAbove > 6) 107 return { x: xJitter, y: edge + spaceAbove * 0.45 }; 108 if (side === "below" && spaceBelow > 6) 109 return { x: xJitter, y: exclude.bottom + spaceBelow * 0.55 }; 110 } 111 112 const cornerX = seed % 2 === 0 ? edge : w - edge; 113 const cornerY = (seed >>> 3) % 2 === 0 ? edge : h - edge; 114 return { x: cornerX, y: cornerY }; 115 } 116 117 function validMembers(members) { 118 return members.filter( 119 (m) => 120 m && 121 typeof m.username === "string" && 122 m.username.length > 0 && 123 typeof m.path === "string" && 124 m.path.length > 0 125 ); 126 } 127 128 /** Viewport estreito: sem «bolinhas» de membros (tocar era difícil e sobrepõe o texto). */ 129 function isStarfieldMobileViewport() { 130 return window.matchMedia("(max-width: 768px)").matches; 131 } 132 133 function renderStarLinks(container, members) { 134 document.querySelectorAll("a.star-member").forEach((el) => el.remove()); 135 if (container) container.replaceChildren(); 136 137 if (isStarfieldMobileViewport()) { 138 return; 139 } 140 141 if (!container) return; 142 143 const host = container.parentElement; 144 const w = host ? host.offsetWidth : window.innerWidth; 145 const h = host ? host.offsetHeight : window.innerHeight; 146 if (w < 32 || h < 32) return; 147 148 const pad = 36; 149 const exclude = inflateRect(documentColumnExcludeRect(host), pad); 150 151 for (const m of validMembers(members)) { 152 const seed = hashUsername(m.username); 153 const { x, y } = findStarPosition(w, h, seed, exclude); 154 const bright = starBrightness(parseSince(m.since)); 155 156 const a = document.createElement("a"); 157 a.className = "star-member"; 158 a.href = m.path; 159 a.setAttribute("aria-label", `Site de ~${m.username}`); 160 a.textContent = `~${m.username}`; 161 a.style.left = `${x}px`; 162 a.style.top = `${y}px`; 163 a.style.opacity = String(0.55 + bright * 0.43); 164 const scale = 0.78 + bright * 0.42; 165 a.style.setProperty("--star-scale", String(scale)); 166 167 container.appendChild(a); 168 } 169 } 170 171 async function main() { 172 const starRoot = document.getElementById("starfield"); 173 174 let members = []; 175 176 try { 177 const res = await fetch("data/members.json", { cache: "no-store" }); 178 if (!res.ok) throw new Error(`HTTP ${res.status}`); 179 members = await res.json(); 180 if (!Array.isArray(members)) throw new Error("members.json inválido"); 181 } catch { 182 members = []; 183 } 184 185 let starRaf = 0; 186 const scheduleStars = () => { 187 cancelAnimationFrame(starRaf); 188 starRaf = requestAnimationFrame(() => { 189 renderStarLinks(starRoot, members); 190 }); 191 }; 192 193 scheduleStars(); 194 195 window.addEventListener("resize", scheduleStars, { passive: true }); 196 197 const host = starRoot?.parentElement; 198 if (host && typeof ResizeObserver !== "undefined") { 199 const ro = new ResizeObserver(() => scheduleStars()); 200 ro.observe(host); 201 } 202 } 203 204 document.addEventListener("DOMContentLoaded", main);