/* ═══ Spotnet — interactions ═══ */ /* ── Hero network mesh canvas ── */ (() => { const canvas = document.getElementById('mesh'); if (!canvas) return; const ctx = canvas.getContext('2d'); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; let W, H, nodes = [], dpr = Math.min(devicePixelRatio || 1, 2); function resize() { const rect = canvas.parentElement.getBoundingClientRect(); W = rect.width; H = rect.height; canvas.width = W * dpr; canvas.height = H * dpr; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); seed(); } function seed() { const count = Math.floor((W * H) / 16000); nodes = Array.from({ length: count }, () => ({ x: Math.random() * W, y: Math.random() * H, vx: (Math.random() - .5) * .35, vy: (Math.random() - .5) * .35, r: Math.random() * 1.6 + .8, })); } const LINK = 130; function frame() { ctx.clearRect(0, 0, W, H); for (const n of nodes) { n.x += n.vx; n.y += n.vy; if (n.x < 0 || n.x > W) n.vx *= -1; if (n.y < 0 || n.y > H) n.vy *= -1; } for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const a = nodes[i], b = nodes[j]; const dx = a.x - b.x, dy = a.y - b.y; const d = Math.hypot(dx, dy); if (d < LINK) { ctx.strokeStyle = `rgba(91,157,245,${(1 - d / LINK) * .16})`; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } } for (const n of nodes) { ctx.fillStyle = 'rgba(91,157,245,.55)'; ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, Math.PI * 2); ctx.fill(); } if (!reduced) requestAnimationFrame(frame); } addEventListener('resize', resize); resize(); frame(); // draws once even when reduced-motion })(); /* ── Scroll reveal ── */ (() => { const io = new IntersectionObserver(entries => { for (const e of entries) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } } }, { threshold: .15, rootMargin: '0px 0px -40px 0px' }); document.querySelectorAll('.reveal').forEach(el => io.observe(el)); // Safety net: browser scroll restoration on reload can land mid-page before // the observer delivers entries — reveal whatever is already in view. function sweep() { document.querySelectorAll('.reveal:not(.in)').forEach(el => { const r = el.getBoundingClientRect(); if (r.top < innerHeight && r.bottom > 0) { el.classList.add('in'); io.unobserve(el); } }); } addEventListener('load', () => setTimeout(sweep, 120)); addEventListener('scroll', sweep, { once: true, passive: true }); })(); /* ── Stat count-up ── */ (() => { const els = document.querySelectorAll('[data-count]'); const io = new IntersectionObserver(entries => { for (const e of entries) { if (!e.isIntersecting) continue; io.unobserve(e.target); const el = e.target; const target = parseFloat(el.dataset.count); const prefix = el.dataset.prefix || ''; const suffix = el.dataset.suffix || ''; const decimals = (el.dataset.count.split('.')[1] || '').length; const t0 = performance.now(), dur = 1400; (function tick(t) { const p = Math.min((t - t0) / dur, 1); const eased = 1 - Math.pow(1 - p, 3); el.textContent = prefix + (target * eased).toFixed(decimals) + suffix; if (p < 1) requestAnimationFrame(tick); })(t0); } }, { threshold: .6 }); els.forEach(el => io.observe(el)); })(); /* ── Telemetry latency jitter ── */ (() => { const feeds = [ { id: 'lat-iix', base: 1.2, jitter: .4 }, { id: 'lat-sg', base: 14.8, jitter: 1.6 }, { id: 'lat-gs', base: 15.3, jitter: 1.8 }, ]; setInterval(() => { for (const f of feeds) { const el = document.getElementById(f.id); if (el) el.textContent = (f.base + (Math.random() - .5) * f.jitter).toFixed(1) + 'ms'; } }, 2400); })(); /* ── Mobile nav ── */ (() => { const burger = document.getElementById('navBurger'); const links = document.getElementById('navLinks'); if (!burger) return; burger.addEventListener('click', () => { const open = links.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); }); links.addEventListener('click', e => { if (e.target.tagName === 'A') { links.classList.remove('open'); burger.setAttribute('aria-expanded', 'false'); } }); })(); /* ── Active nav link on scroll ── */ (() => { const sections = ['network', 'solutions', 'matrix', 'why'].map(id => document.getElementById(id)); const links = document.querySelectorAll('.nav-links a'); const io = new IntersectionObserver(entries => { for (const e of entries) { if (!e.isIntersecting) continue; links.forEach(a => a.classList.toggle('active', a.getAttribute('href') === '#' + e.target.id)); } }, { rootMargin: '-40% 0px -55% 0px' }); sections.forEach(s => s && io.observe(s)); })(); /* ── Quote form → mailto ── */ (() => { const form = document.getElementById('quoteForm'); const note = document.getElementById('formNote'); if (!form) return; form.addEventListener('submit', e => { e.preventDefault(); const d = Object.fromEntries(new FormData(form).entries()); if (!d.name || !d.company || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(d.email || '')) { note.textContent = '! Please fill name, company and a valid work email.'; note.classList.add('err'); return; } note.classList.remove('err'); const subject = encodeURIComponent(`[Quote] ${d.service} — ${d.company}`); const body = encodeURIComponent( `Name: ${d.name}\nCompany: ${d.company}\nEmail: ${d.email}\nService: ${d.service}\n\n${d.message || ''}` ); location.href = `mailto:sales@spot.net.id?subject=${subject}&body=${body}`; note.textContent = '✓ Opening your mail client — inquiry drafted to sales@spot.net.id'; }); })();