/* sener labs — orbital system engine (vanilla) */
(function () {
"use strict";
// ---- palettes ----------------------------------------------------------
const PALETTES = {
cosmic: {
bgCore: "#160d2e", bgEdge: "#070512", star: "#cbb8ff",
neb: ["#7c3aed", "#2563eb", "#db2777"],
orbs: {
"value-invest": ["#5eead4", "#a3e635"],
"dinner": ["#fb7185", "#fb923c"],
"lab-1": ["#a78bfa", "#7c3aed"],
"lab-2": ["#38bdf8", "#6366f1"],
},
},
sunset: {
bgCore: "#2a0f24", bgEdge: "#0a0410", star: "#ffd9a8",
neb: ["#f97316", "#db2777", "#fbbf24"],
orbs: {
"value-invest": ["#fbbf24", "#f97316"],
"dinner": ["#fb7185", "#e11d48"],
"lab-1": ["#f472b6", "#a21caf"],
"lab-2": ["#fdba74", "#f59e0b"],
},
},
neon: {
bgCore: "#05121a", bgEdge: "#02060a", star: "#aef7ff",
neb: ["#06b6d4", "#22d3ee", "#a3e635"],
orbs: {
"value-invest": ["#22d3ee", "#34d399"],
"dinner": ["#f472b6", "#22d3ee"],
"lab-1": ["#a3e635", "#22d3ee"],
"lab-2": ["#818cf8", "#22d3ee"],
},
},
aurora: {
bgCore: "#07211c", bgEdge: "#04100c", star: "#bdfce8",
neb: ["#10b981", "#22d3ee", "#a78bfa"],
orbs: {
"value-invest": ["#34d399", "#22d3ee"],
"dinner": ["#f0abfc", "#a78bfa"],
"lab-1": ["#5eead4", "#34d399"],
"lab-2": ["#93c5fd", "#a78bfa"],
},
},
};
// ---- projects ----------------------------------------------------------
const PROJECTS = [
{
id: "value-invest", name: "Value-Invest", status: "live",
tag: "Evaluate stocks with AI-generated sentiment & outlook.",
url: "https://invest.sener-labs.com",
rxF: 0.30, ryF: 0.135, size: 96, speed: 0.085, phase: 0.4,
},
{
id: "dinner", name: "dinner app", status: "soon",
tag: "Swipe meals like Tinder — when everyone matches, dinner's decided.",
url: null,
rxF: 0.435, ryF: 0.188, size: 72, speed: -0.062, phase: 2.2,
},
{
id: "lab-1", name: "in the lab", status: "placeholder",
tag: "A new idea taking shape.", url: null,
rxF: 0.225, ryF: 0.10, size: 46, speed: 0.124, phase: 4.1,
},
{
id: "lab-2", name: "in the lab", status: "placeholder",
tag: "Something is coming.", url: null,
rxF: 0.49, ryF: 0.207, size: 38, speed: 0.047, phase: 5.4,
},
];
// ---- state -------------------------------------------------------------
const state = {
palette: "cosmic",
speed: 1,
rings: true,
starDensity: 1,
hovered: null,
};
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// ---- dom ---------------------------------------------------------------
const sky = document.getElementById("sky");
const ctx = sky.getContext("2d");
const planetsLayer = document.getElementById("planets");
const core = document.getElementById("core");
const nebEls = [...document.querySelectorAll(".neb")];
const card = document.getElementById("card");
const veil = document.getElementById("veil");
let W = 0, H = 0, DPR = 1;
let stars = [];
const runtime = {}; // id -> { angle, el, orb, label }
// ---- helpers -----------------------------------------------------------
function shade(hex, amt) {
const n = parseInt(hex.slice(1), 16);
let r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
r = Math.round(r * (1 + amt)); g = Math.round(g * (1 + amt)); b = Math.round(b * (1 + amt));
r = Math.max(0, Math.min(255, r)); g = Math.max(0, Math.min(255, g)); b = Math.max(0, Math.min(255, b));
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
function hexA(hex, a) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${a})`;
}
// ---- build planets -----------------------------------------------------
function buildPlanets() {
PROJECTS.forEach((p) => {
const el = document.createElement("div");
el.className = "planet " + (p.status === "placeholder" ? "placeholder" : "");
el.dataset.id = p.id;
const wrap = document.createElement("div");
wrap.className = "orb-wrap";
const isLink = p.status === "live" && p.url;
const orb = document.createElement(isLink ? "a" : "div");
orb.className = "orb";
orb.style.width = p.size + "px";
orb.style.height = p.size + "px";
if (isLink) { orb.href = p.url; orb.setAttribute("aria-label", p.name); }
else { orb.setAttribute("role", "img"); orb.setAttribute("aria-label", p.name); }
wrap.appendChild(orb);
el.appendChild(wrap);
if (p.status !== "placeholder") {
const label = document.createElement("span");
label.className = "plabel";
label.textContent = p.name;
el.appendChild(label);
runtime[p.id] = { angle: p.phase, el, orb, label };
} else {
runtime[p.id] = { angle: p.phase, el, orb, label: null };
}
// interactions
el.addEventListener("pointerenter", () => setHover(p.id));
el.addEventListener("pointerleave", () => setHover(null));
if (!isLink) {
orb.addEventListener("click", () => {
el.classList.remove("wobble");
void el.offsetWidth;
el.classList.add("wobble");
});
}
planetsLayer.appendChild(el);
});
}
function setHover(id) {
if (state.hovered === id) return;
state.hovered = id;
PROJECTS.forEach((p) => {
runtime[p.id].el.classList.toggle("hover", p.id === id);
});
if (id) showCard(id); else hideCard();
}
function showCard(id) {
const p = PROJECTS.find((x) => x.id === id);
const orbCols = PALETTES[state.palette].orbs[id];
card.style.setProperty("--glow", orbCols[0]);
card.className = "card show " + (p.status === "live" ? "live" : p.status);
const statusText = p.status === "live" ? "live now" : p.status === "soon" ? "in the lab" : "coming soon";
let html =
`${statusText}` +
`
${p.name}
` +
`${p.tag}
`;
if (p.status === "live") {
const host = p.url.replace(/^https?:\/\//, "");
html += `${host} ↗`;
}
card.innerHTML = html;
}
function hideCard() { card.className = "card"; }
// ---- palette application ----------------------------------------------
function applyPalette(name) {
state.palette = name;
const pal = PALETTES[name];
const root = document.documentElement.style;
root.setProperty("--bg-core", pal.bgCore);
root.setProperty("--bg-edge", pal.bgEdge);
root.setProperty("--star", pal.star);
nebEls.forEach((el, i) => {
el.style.background = `radial-gradient(circle, ${pal.neb[i]} 0%, transparent 66%)`;
});
PROJECTS.forEach((p) => {
if (p.status === "placeholder") return;
const c = pal.orbs[p.id];
const orb = runtime[p.id].orb;
orb.style.background = `radial-gradient(circle at 32% 26%, ${shade(c[0], 0.25)} 0%, ${c[0]} 32%, ${c[1]} 76%, ${shade(c[1], -0.4)} 100%)`;
runtime[p.id].el.style.setProperty("--glow", hexA(c[0], 0.85));
runtime[p.id].orb.style.setProperty("--glow", hexA(c[0], 0.6));
});
}
// ---- starfield ---------------------------------------------------------
function buildStars() {
const base = Math.round((W * H) / 5400);
const count = Math.round(base * state.starDensity);
stars = [];
for (let i = 0; i < count; i++) {
stars.push({
x: Math.random() * W,
y: Math.random() * H,
z: Math.random(), // depth 0..1
r: Math.random() * 1.3 + 0.25,
tw: Math.random() * Math.PI * 2, // twinkle phase
});
}
}
// ---- resize ------------------------------------------------------------
function resize() {
W = window.innerWidth; H = window.innerHeight;
DPR = Math.min(window.devicePixelRatio || 1, 2);
sky.width = W * DPR; sky.height = H * DPR;
sky.style.width = W + "px"; sky.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
buildStars();
}
// ---- pointer parallax --------------------------------------------------
let mx = 0, my = 0, emx = 0, emy = 0;
window.addEventListener("pointermove", (e) => {
mx = (e.clientX - W / 2) / (W / 2);
my = (e.clientY - H / 2) / (H / 2);
});
window.addEventListener("pointerout", (e) => { if (!e.relatedTarget) { mx = 0; my = 0; } });
// ---- main loop ---------------------------------------------------------
let last = performance.now();
let intro = 0;
const palStar = () => PALETTES[state.palette].star;
function frame(now) {
let dt = (now - last) / 1000; last = now;
if (dt > 0.05) dt = 0.05;
intro = Math.min(1, intro + dt / 1.3);
const introE = 1 - Math.pow(1 - intro, 3); // easeOutCubic
// ease pointer
emx += (mx - emx) * 0.06;
emy += (my - emy) * 0.06;
const cx = W / 2 + emx * 12;
const cy = H / 2 + emy * 9;
const base = Math.min(W, 1440);
const tsec = now / 1000;
// ---- draw sky (stars + rings) ----
ctx.clearRect(0, 0, W, H);
// stars
const sc = palStar();
const scn = parseInt(sc.slice(1), 16);
const sr = (scn >> 16) & 255, sg = (scn >> 8) & 255, sb = scn & 255;
for (let i = 0; i < stars.length; i++) {
const s = stars[i];
const px = s.x + emx * (8 + s.z * 34);
const py = s.y + emy * (6 + s.z * 26);
const a = (0.25 + s.z * 0.55) * (0.6 + 0.4 * Math.sin(tsec * 1.2 + s.tw)) * introE;
ctx.beginPath();
ctx.arc(px, py, s.r * (0.6 + s.z), 0, Math.PI * 2);
ctx.fillStyle = `rgba(${sr},${sg},${sb},${a})`;
ctx.fill();
}
// orbit rings
if (state.rings) {
ctx.lineWidth = 1;
PROJECTS.forEach((p) => {
const rx = p.rxF * base, ry = p.ryF * base;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${sr},${sg},${sb},${0.10 * introE})`;
ctx.stroke();
});
}
// core parallax
core.style.transform = `translate(-50%, -50%) translate(${emx * -14}px, ${emy * -10}px)`;
// nebula parallax + slow drift
nebEls.forEach((el, i) => {
const dir = i % 2 === 0 ? 1 : -1;
const dx = emx * (24 + i * 12) * dir + Math.sin(tsec * 0.08 + i) * 14;
const dy = emy * (18 + i * 10) * dir + Math.cos(tsec * 0.07 + i) * 12;
el.style.transform = `translate(${dx}px, ${dy}px)`;
});
// ---- planets ----
PROJECTS.forEach((p) => {
const r = runtime[p.id];
const hovered = state.hovered === p.id;
const otherHover = state.hovered && !hovered;
const spd = p.speed * state.speed * (reduceMotion ? 0.25 : 1) * (hovered ? 0.12 : 1);
r.angle += spd * dt;
const c = Math.cos(r.angle), s = Math.sin(r.angle);
const depth = (s + 1) / 2; // 0 back .. 1 front
const rx = p.rxF * base, ry = p.ryF * base;
const bob = Math.sin(tsec * 0.6 + p.phase) * 4;
let x = cx + c * rx * introE;
let y = cy + s * ry * introE + bob;
// per-depth parallax (closer = more)
x += emx * (6 + depth * 22);
y += emy * (5 + depth * 16);
const k = (0.6 + 0.4 * depth) * (hovered ? 1.14 : 1) * (otherHover ? 0.95 : 1) * (0.4 + 0.6 * introE);
const z = Math.round(10 + depth * 80);
const op = (0.5 + 0.5 * depth) * introE * (otherHover ? 0.7 : 1);
r.el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
r.el.style.zIndex = z;
r.el.style.opacity = op;
r.orb.parentElement.style.transform = `translate(-50%, -50%) scale(${k})`;
if (r.label) {
r.label.style.transform = `translate(-50%, ${(p.size / 2) * k + 13}px)`;
}
// position hover card above the orb
if (hovered) {
card.style.left = x + "px";
card.style.top = (y - (p.size / 2) * k - 16) + "px";
}
});
requestAnimationFrame(frame);
}
// ---- public api for tweaks --------------------------------------------
window.Orbit = {
setPalette: applyPalette,
setSpeed: (v) => { state.speed = v; },
setRings: (v) => { state.rings = !!v; },
setStarDensity: (v) => { state.starDensity = v; buildStars(); },
palettes: Object.keys(PALETTES),
};
// ---- boot --------------------------------------------------------------
function boot() {
resize();
buildPlanets();
applyPalette(state.palette);
window.addEventListener("resize", resize);
requestAnimationFrame(frame);
// reveal: paint the opaque veil first, then trigger the fade, with a hard fallback
void veil.offsetWidth;
setTimeout(() => {
veil.classList.add("gone");
setTimeout(() => { veil.style.display = "none"; }, 850);
}, 60);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();