runv-server

server tooling for runv.club
Log | Files | Refs | README

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);