Files
OTSSigns-Website/theme/assets/js/main.js

756 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OTS Theme - Main JS
*/
/* ── Theme toggle (runs before DOMContentLoaded to prevent flash) ── */
(function() {
const saved = localStorage.getItem('oribi-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = saved || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
document.addEventListener('DOMContentLoaded', () => {
/* ── Sticky header ──────────────────────────────────────── */
const header = document.getElementById('site-header');
if (header) {
window.addEventListener('scroll', () => {
header.classList.toggle('scrolled', window.scrollY > 40);
}, { passive: true });
/* Detect whether the hero beneath the header has a light background.
.hero (homepage) is white in light mode; .page-hero stays dark.
Re-evaluate when the theme toggle changes data-theme. */
function updateHeroContrast() {
const isLight = document.documentElement.getAttribute('data-theme') !== 'dark';
const hasLightHero = isLight && document.querySelector('.hero') && !document.querySelector('.page-hero');
header.classList.toggle('over-light-hero', !!hasLightHero);
}
updateHeroContrast();
new MutationObserver(updateHeroContrast).observe(document.documentElement, {
attributes: true, attributeFilter: ['data-theme']
});
}
/* ── Mobile nav toggle ──────────────────────────────────── */
const toggle = document.getElementById('nav-toggle');
const nav = document.getElementById('site-nav');
if (toggle && nav) {
toggle.addEventListener('click', () => {
toggle.classList.toggle('open');
nav.classList.toggle('open');
document.body.classList.toggle('menu-open');
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', !expanded);
});
}
/* ── Mobile sub-menu accordion ──────────────────────────── */
if (nav) {
nav.addEventListener('click', (e) => {
// Only act in mobile (nav-toggle visible = mobile view)
if (!toggle || getComputedStyle(toggle).display === 'none') return;
const parentLi = e.target.closest('.menu-item-has-children');
if (!parentLi) return;
// If the click is on the parent link itself (not a child link), toggle
const clickedLink = e.target.closest('a');
const subMenu = parentLi.querySelector(':scope > .sub-menu');
if (!subMenu) return;
// If they clicked a sub-menu link, let it navigate normally
if (clickedLink && subMenu.contains(clickedLink)) return;
// Toggle this item; collapse siblings
const isOpen = parentLi.classList.contains('submenu-open');
parentLi.closest('.nav-menu')
.querySelectorAll('.menu-item-has-children.submenu-open')
.forEach(li => li.classList.remove('submenu-open'));
if (!isOpen) {
parentLi.classList.add('submenu-open');
// Prevent the parent anchor from navigating when toggling
if (clickedLink && parentLi.contains(clickedLink) && !subMenu.contains(clickedLink)) {
e.preventDefault();
}
}
});
}
/* ── Scroll-to-top ──────────────────────────────────────── */
const scrollBtn = document.getElementById('scroll-top');
if (scrollBtn) {
window.addEventListener('scroll', () => {
scrollBtn.classList.toggle('visible', window.scrollY > 600);
}, { passive: true });
scrollBtn.addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
/* ── Light / Dark theme toggle ─────────────────────────── */
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
const current = document.documentElement.getAttribute('data-theme') || 'light';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('oribi-theme', next);
themeToggle.setAttribute('aria-label',
next === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'
);
});
}
/* ── Contact form (AJAX) ────────────────────────────────── */
const form = document.getElementById('contact-form');
const notice = document.getElementById('form-notice');
if (form && typeof oribiAjax !== 'undefined') {
form.addEventListener('submit', async (e) => {
e.preventDefault();
notice.className = 'form-notice';
notice.style.display = 'none';
const data = new FormData(form);
data.append('action', 'oribi_contact');
data.append('nonce', oribiAjax.nonce);
try {
const res = await fetch(oribiAjax.url, { method: 'POST', body: data });
const json = await res.json();
notice.textContent = json.data;
notice.className = 'form-notice ' + (json.success ? 'success' : 'error');
notice.style.display = 'block';
if (json.success) form.reset();
} catch {
notice.textContent = 'Something went wrong. Please try again.';
notice.className = 'form-notice error';
notice.style.display = 'block';
}
});
}
/* ── Animate cards on scroll ────────────────────────────── */
const cards = document.querySelectorAll('.oribi-card, .feature-card, .industry-card, .pricing-card, .value-card, .platform-row');
if (cards.length && 'IntersectionObserver' in window &&
!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
cards.forEach(c => c.classList.add('scroll-hidden'));
/* Use rAF to ensure the class is applied before observing avoids
Safari quirk where elements already in-viewport don't fire. */
requestAnimationFrame(() => {
const io = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.remove('scroll-hidden');
entry.target.classList.add('scroll-visible');
io.unobserve(entry.target);
}
});
}, { threshold: 0.05, rootMargin: '0px 0px 80px 0px' });
cards.forEach(c => io.observe(c));
});
/* Safety net: reveal any still-hidden elements after 4 s so content
is never permanently invisible (e.g. iOS Safari edge-cases). */
setTimeout(() => {
cards.forEach(c => {
if (c.classList.contains('scroll-hidden')) {
c.classList.remove('scroll-hidden');
c.classList.add('scroll-visible');
}
});
}, 4000);
}
});
/* -- Datacenter hero background canvas ----------------------------------------- */
(function () {
const canvas = document.getElementById('dc-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
/*
* Performance:
* 1. bgCanvas -- static geometry painted once per resize, blit each frame.
* 2. LED draws batched by colour -- one shadowBlur setup per colour (~4/frame).
* 3. 30 fps cap.
* 4. Visibility API + IntersectionObserver pause rAF when hidden/off-screen.
*/
/* -- colour palette -------------------------------------------------------- */
const ROOM_TOP = '#020509';
const ROOM_BOT = '#030b08';
const RACK_SHELL = '#111b2e'; /* outer frame -- dark navy */
const RACK_SHELL2 = '#0c1422'; /* outer frame shadow side */
const RACK_FACE = '#0d1728'; /* inner face panel background */
const RAIL_FACE = '#141f35'; /* mounting rail column face */
const RAIL_EDGE = '#1c2d4a'; /* rail inner-edge highlight */
const SCREW_COL = '#0a1220'; /* screw/nut recesses on rails */
const SRV_FACE = '#1a2840'; /* server 1U face -- lighter than rack */
const SRV_STRIPE = '#1f3050'; /* top-edge highlight stripe */
const SRV_SHADOW = '#0e1a2b'; /* bottom-edge shadow line */
const SRV_OFF = '#0d1624'; /* unpowered slot */
const PWRBTN_RING = '#243654'; /* power button outer ring */
const PWRBTN_FACE = '#101e30'; /* power button recessed face */
const VENT_SLOT = '#0b1421'; /* vent/louver slots */
const BAY_SLOT = '#09111e'; /* drive bay recesses */
const BAY_EDGE = '#1d2f48'; /* drive bay raised edge */
const PATCH_BODY = '#111d30'; /* patch unit body */
const PATCH_PORT = '#070d18'; /* patch port holes */
const PATCH_LBL = '#0b1422'; /* patch label strip */
const CAB_TROUGH = '#080f1c'; /* cable management trough */
const LED_OFF = '#182438'; /* unlit LED placeholder */
const LED_COLORS = { green: '#00f07a', amber: '#ffb200', red: '#ff3838', blue: '#00aaff' };
/* -- depth layers (back -> front)
railW = thickness of side mounting-rail columns
padTop = vertical padding inside rack before first unit -- */
const LAYERS = [
{ alpha: 0.28, yShift: 0.12, rackW: 54, rackGap: 18, unitH: 12, unitGap: 1, railW: 5, padTop: 7, ledSz: 2, ledCols: 3, ledRows: 2 },
{ alpha: 0.55, yShift: 0.05, rackW: 80, rackGap: 28, unitH: 18, unitGap: 2, railW: 7, padTop: 9, ledSz: 3, ledCols: 3, ledRows: 2 },
{ alpha: 1.00, yShift: 0.00, rackW: 112, rackGap: 40, unitH: 25, unitGap: 2, railW: 10, padTop: 11, ledSz: 4, ledCols: 4, ledRows: 2 },
];
/* -- shared LED-position helpers (same formula in paintBg & drawLEDs) ----- */
function ledOriginX(ux, uw, ledCols, ledSz) {
return ux + uw - ledCols * (ledSz + 3) - 3;
}
function ledOriginY(uy, unitH, ledRows, ledSz) {
return uy + ((unitH - ledRows * (ledSz + 2) + 1) / 2 | 0);
}
/* -- state ----------------------------------------------------------------- */
let layers = [];
const bgCanvas = document.createElement('canvas');
const bgCtx = bgCanvas.getContext('2d');
let scanY = 0;
let lastTs = 0;
let rafId;
let W = 1, H = 1;
const FRAME_MS = 1000 / 30; /* 30 fps throttle */
let fpsDebt = 0;
/* -- build rack data for one layer ---------------------------------------- */
function buildLayer(def) {
const { rackW, rackGap, unitH, unitGap, railW, padTop, ledCols, ledRows, yShift } = def;
const numRacks = Math.ceil(W / (rackW + rackGap)) + 2;
const racks = [];
for (let r = 0; r < numRacks; r++) {
const rx = r * (rackW + rackGap) - rackGap;
const numUnits = Math.floor((H * (1 - Math.abs(yShift) * 2) - padTop * 2) / (unitH + unitGap));
const units = [];
const activity = {
timer: Math.random() * 8000, period: 5000 + Math.random() * 12000,
active: false, burstTimer: 0, burstLength: 0,
};
for (let u = 0; u < numUnits; u++) {
const leds = [];
for (let l = 0; l < ledCols * ledRows; l++) {
const rnd = Math.random();
const type = rnd < 0.68 ? 'green' : rnd < 0.86 ? 'amber' : rnd < 0.96 ? 'red' : 'blue';
leds.push({ type, on: Math.random() > 0.2,
blinkPeriod: 400 + Math.random() * 5000,
timer: Math.random() * 5000 });
}
units.push({ leds, powered: Math.random() > 0.08 });
}
racks.push({ rx, units, activity, patchRows: 2 + Math.floor(Math.random() * 2) });
}
return racks;
}
/* -- paint static geometry to bgCanvas (once per resize) ------------------ */
function paintBg() {
bgCanvas.width = W;
bgCanvas.height = H;
/* room background */
const bg = bgCtx.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, ROOM_TOP);
bg.addColorStop(0.5, '#05080f');
bg.addColorStop(1, ROOM_BOT);
bgCtx.fillStyle = bg;
bgCtx.fillRect(0, 0, W, H);
/* ceiling fluorescent bar */
const ceil = bgCtx.createLinearGradient(0, 0, 0, H * 0.10);
ceil.addColorStop(0, 'rgba(140,190,255,0.13)');
ceil.addColorStop(1, 'rgba(140,190,255,0)');
bgCtx.fillStyle = ceil;
bgCtx.fillRect(0, 0, W, H * 0.10);
/* floor glow */
const flr = bgCtx.createLinearGradient(0, H * 0.84, 0, H);
flr.addColorStop(0, 'rgba(0,220,110,0)');
flr.addColorStop(1, 'rgba(0,180,90,0.10)');
bgCtx.fillStyle = flr;
bgCtx.fillRect(0, H * 0.84, W, H * 0.16);
for (const layer of layers) {
const { def, racks } = layer;
const { rackW, unitH, unitGap, railW, padTop, ledSz, ledCols, ledRows, alpha, yShift } = def;
const layerY = H * yShift;
const uw = rackW - 2 * railW;
bgCtx.save();
bgCtx.globalAlpha = alpha;
for (const rack of racks) {
const { rx, units, patchRows } = rack;
const totalRows = units.length + patchRows;
const rackH = totalRows * (unitH + unitGap) - unitGap + padTop * 2 + 6;
const ry = Math.floor((H - rackH) / 2) + layerY;
const ux = rx + railW;
/* 1. rack shadow (cartoon depth) */
bgCtx.fillStyle = RACK_SHELL2;
bgCtx.fillRect(rx + 2, ry + 2, rackW, rackH);
/* 2. rack outer shell */
bgCtx.fillStyle = RACK_SHELL;
bgCtx.fillRect(rx, ry, rackW, rackH);
/* cartoon top/left highlights */
bgCtx.fillStyle = 'rgba(255,255,255,0.13)';
bgCtx.fillRect(rx, ry, rackW, 1);
bgCtx.fillStyle = 'rgba(255,255,255,0.07)';
bgCtx.fillRect(rx, ry, 1, rackH);
/* 3. inner face panel */
bgCtx.fillStyle = RACK_FACE;
bgCtx.fillRect(ux, ry + 1, uw, rackH - 2);
/* 4. mounting rail columns */
[rx, rx + rackW - railW].forEach(function(cx) {
bgCtx.fillStyle = RAIL_FACE;
bgCtx.fillRect(cx, ry, railW, rackH);
/* inner-edge accent */
const edgeX = (cx === rx) ? cx + railW - 1 : cx;
bgCtx.fillStyle = RAIL_EDGE;
bgCtx.fillRect(edgeX, ry, 1, rackH);
/* screw holes */
const screwX = cx + (railW / 2 | 0) - 1;
const screwSz = Math.max(2, railW * 0.28 | 0);
let sy = ry + padTop;
while (sy < ry + rackH - padTop) {
bgCtx.fillStyle = SCREW_COL;
bgCtx.fillRect(screwX, sy, screwSz, screwSz);
sy += unitH + unitGap;
}
});
/* 5. top cap brace */
bgCtx.fillStyle = 'rgba(255,255,255,0.05)';
bgCtx.fillRect(ux, ry, uw, padTop);
/* 6. cable management trough */
bgCtx.fillStyle = CAB_TROUGH;
bgCtx.fillRect(ux, ry + rackH - padTop - 2, uw, padTop + 2);
bgCtx.fillStyle = 'rgba(255,255,255,0.04)';
bgCtx.fillRect(ux, ry + rackH - padTop - 2, uw, 1);
/* 7. patch panel rows */
for (let p = 0; p < patchRows; p++) {
const py = ry + padTop + p * (unitH + unitGap);
bgCtx.fillStyle = PATCH_BODY;
bgCtx.fillRect(ux, py, uw, unitH);
/* top bevel + bottom shadow */
bgCtx.fillStyle = 'rgba(255,255,255,0.08)';
bgCtx.fillRect(ux, py, uw, 1);
bgCtx.fillStyle = 'rgba(0,0,0,0.38)';
bgCtx.fillRect(ux, py + unitH - 1, uw, 1);
/* label strip (left ~18%) */
const lblW = Math.max(6, uw * 0.18 | 0);
bgCtx.fillStyle = PATCH_LBL;
bgCtx.fillRect(ux + 2, py + 2, lblW, unitH - 4);
/* RJ-45 style port holes */
const portsX = ux + lblW + 4;
const portsW = uw - lblW - 6;
const portW = Math.max(3, unitH * 0.50 | 0);
const portH = Math.max(2, unitH * 0.38 | 0);
const portY = py + ((unitH - portH) / 2 | 0);
const numPort = Math.floor(portsW / (portW + 2));
bgCtx.fillStyle = PATCH_PORT;
for (let pp = 0; pp < numPort; pp++) {
bgCtx.fillRect(portsX + pp * (portW + 2), portY, portW, portH);
}
}
/* 8. server units */
for (let u = 0; u < units.length; u++) {
const unit = units[u];
const uy = ry + padTop + (u + patchRows) * (unitH + unitGap);
if (!unit.powered) {
/* empty / powered-off slot */
bgCtx.fillStyle = SRV_OFF;
bgCtx.fillRect(ux, uy, uw, unitH);
bgCtx.fillStyle = 'rgba(0,0,0,0.32)';
bgCtx.fillRect(ux, uy + unitH - 1, uw, 1);
continue;
}
/* server face */
bgCtx.fillStyle = SRV_FACE;
bgCtx.fillRect(ux, uy, uw, unitH);
/* top highlight stripe */
bgCtx.fillStyle = SRV_STRIPE;
bgCtx.fillRect(ux, uy, uw, 1);
/* bottom shadow line */
bgCtx.fillStyle = SRV_SHADOW;
bgCtx.fillRect(ux, uy + unitH - 1, uw, 1);
/* -- power button (left section) -- */
if (unitH >= 12) {
const pBtnR = Math.max(2, unitH * 0.18 | 0);
const pBtnX = ux + railW + pBtnR + 1;
const pBtnY = uy + (unitH / 2 | 0) - pBtnR;
/* outer ring */
bgCtx.fillStyle = PWRBTN_RING;
bgCtx.fillRect(pBtnX - 1, pBtnY - 1, pBtnR * 2 + 2, pBtnR * 2 + 2);
/* inner recess */
bgCtx.fillStyle = PWRBTN_FACE;
bgCtx.fillRect(pBtnX, pBtnY, pBtnR * 2, pBtnR * 2);
}
/* -- vent / louver slots -- */
if (unitH >= 14) {
const pBtnR = Math.max(2, unitH * 0.18 | 0);
const ventStartX = ux + railW + pBtnR * 2 + 5;
const ventH = Math.max(3, unitH - 6);
const ventY = uy + 3;
const numVents = Math.min(5, Math.floor(uw * 0.10 / 3));
bgCtx.fillStyle = VENT_SLOT;
for (let v = 0; v < numVents; v++) {
bgCtx.fillRect(ventStartX + v * 3, ventY, 1, ventH);
}
}
/* -- drive bays (centre section) -- */
const ledPanelW = ledCols * (ledSz + 3) + 5;
const rightStop = ux + uw - ledPanelW;
const leftStop = ux + Math.max(
railW * 2 + (unitH * 0.18 | 0) * 2 + (unitH >= 14 ? 14 : 4) + 4,
uw * 0.22 | 0
);
const bayAreaW = rightStop - leftStop;
if (bayAreaW > 8 && unitH >= 10) {
const bayH = Math.max(3, unitH * 0.46 | 0);
const bayW = Math.max(4, Math.min(11, bayAreaW / 5 | 0));
const bayY = uy + ((unitH - bayH) / 2 | 0);
const numBays = Math.min(8, Math.floor(bayAreaW / (bayW + 2)));
for (let b = 0; b < numBays; b++) {
const bx = leftStop + b * (bayW + 2);
/* bay recess */
bgCtx.fillStyle = BAY_SLOT;
bgCtx.fillRect(bx, bayY, bayW, bayH);
/* bay top-edge highlight */
bgCtx.fillStyle = BAY_EDGE;
bgCtx.fillRect(bx, bayY, bayW, 1);
}
}
/* -- off-state LED panel (right section) -- */
const lox = ledOriginX(ux, uw, ledCols, ledSz);
const loy = ledOriginY(uy, unitH, ledRows, ledSz);
/* panel inset background */
bgCtx.fillStyle = 'rgba(0,0,0,0.28)';
bgCtx.fillRect(lox - 2, uy + 2, ledCols * (ledSz + 3) + 1, unitH - 4);
/* placeholder dots */
bgCtx.fillStyle = LED_OFF;
for (let row = 0; row < ledRows; row++) {
for (let col = 0; col < ledCols; col++) {
bgCtx.fillRect(
lox + col * (ledSz + 3),
loy + row * (ledSz + 2),
ledSz, ledSz
);
}
}
}
}
bgCtx.restore();
}
}
/* -- collect LED state & draw all lit LEDs batched by colour -------------- */
function drawLEDs(dt) {
const buckets = { green: [], amber: [], red: [], blue: [] };
for (const layer of layers) {
const { def, racks } = layer;
const { rackW, unitH, unitGap, railW, padTop, ledSz, ledCols, ledRows, alpha, yShift } = def;
const uw = rackW - 2 * railW;
const layerY = H * yShift;
for (const rack of racks) {
const { rx, units, activity, patchRows } = rack;
const totalRows = units.length + patchRows;
const rackH = totalRows * (unitH + unitGap) - unitGap + padTop * 2 + 6;
const ry = Math.floor((H - rackH) / 2) + layerY;
const ux = rx + railW;
/* rack status strip on left rail */
buckets['green'].push({
x: rx + 1, y: ry + padTop, w: 2, h: rackH - padTop * 2,
a: alpha * 0.28,
});
/* per-unit LEDs */
for (let u = 0; u < units.length; u++) {
const unit = units[u];
if (!unit.powered) continue;
const uy = ry + padTop + (u + patchRows) * (unitH + unitGap);
const lox = ledOriginX(ux, uw, ledCols, ledSz);
const loy = ledOriginY(uy, unitH, ledRows, ledSz);
for (let row = 0; row < ledRows; row++) {
for (let col = 0; col < ledCols; col++) {
const led = unit.leds[row * ledCols + col];
led.timer += dt;
if (led.timer >= led.blinkPeriod) {
led.timer = 0;
if (led.type === 'amber' || Math.random() < 0.2) led.on = !led.on;
}
if (!led.on) continue;
buckets[led.type].push({
x: lox + col * (ledSz + 3),
y: loy + row * (ledSz + 2),
w: ledSz, h: ledSz, a: alpha * 0.92,
});
}
}
}
}
}
/* one shadowBlur setup per colour */
for (const [key, rects] of Object.entries(buckets)) {
if (!rects.length) continue;
const color = LED_COLORS[key];
ctx.shadowColor = color;
ctx.shadowBlur = 8;
ctx.fillStyle = color;
for (const r of rects) {
ctx.globalAlpha = r.a;
ctx.fillRect(r.x, r.y, r.w, r.h);
}
}
ctx.shadowBlur = 0;
ctx.shadowColor = 'transparent';
ctx.globalAlpha = 1;
}
/* -- main render loop ------------------------------------------------------ */
function draw(ts) {
rafId = requestAnimationFrame(draw);
const elapsed = ts - lastTs;
lastTs = ts;
fpsDebt += elapsed;
if (fpsDebt < FRAME_MS) return;
const dt = Math.min(fpsDebt, 80);
fpsDebt = fpsDebt % FRAME_MS;
/* blit static geometry */
ctx.drawImage(bgCanvas, 0, 0);
/* mid-aisle ambient glow */
const aisle = ctx.createLinearGradient(0, H * 0.46, 0, H * 0.58);
aisle.addColorStop(0, 'rgba(0,80,180,0)');
aisle.addColorStop(0.5, 'rgba(0,80,180,0.022)');
aisle.addColorStop(1, 'rgba(0,80,180,0)');
ctx.fillStyle = aisle;
ctx.fillRect(0, H * 0.44, W, H * 0.16);
/* lit LEDs */
drawLEDs(dt);
/* sweeping scan line */
scanY = (scanY + 0.28 * (dt / 16)) % (H + 200);
const sg = ctx.createLinearGradient(0, scanY - 110, 0, scanY + 110);
sg.addColorStop(0, 'rgba(60,180,255,0)');
sg.addColorStop(0.45, 'rgba(60,180,255,0.025)');
sg.addColorStop(0.5, 'rgba(60,180,255,0.06)');
sg.addColorStop(0.55, 'rgba(60,180,255,0.025)');
sg.addColorStop(1, 'rgba(60,180,255,0)');
ctx.fillStyle = sg;
ctx.fillRect(0, scanY - 110, W, 220);
/* vignette */
const vig = ctx.createRadialGradient(W/2, H/2, H * 0.25, W/2, H/2, H * 0.88);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.60)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, W, H);
}
/* -- lifecycle helpers ----------------------------------------------------- */
function start() {
if (!rafId) { lastTs = performance.now(); rafId = requestAnimationFrame(draw); }
}
function stop() {
cancelAnimationFrame(rafId); rafId = null;
}
document.addEventListener('visibilitychange', () => {
document.hidden ? stop() : start();
});
if ('IntersectionObserver' in window) {
new IntersectionObserver(entries => {
entries.forEach(e => e.isIntersecting ? start() : stop());
}, { threshold: 0.01 }).observe(canvas);
}
window.addEventListener('resize', () => {
canvas.width = canvas.offsetWidth || canvas.parentElement.offsetWidth;
canvas.height = canvas.offsetHeight || canvas.parentElement.offsetHeight;
W = canvas.width;
H = canvas.height;
layers = LAYERS.map(def => ({ def, racks: buildLayer(def) }));
paintBg();
}, { passive: true });
/* -- init ------------------------------------------------------------------ */
canvas.width = canvas.offsetWidth || canvas.parentElement.offsetWidth;
canvas.height = canvas.offsetHeight || canvas.parentElement.offsetHeight;
W = canvas.width;
H = canvas.height;
layers = LAYERS.map(def => ({ def, racks: buildLayer(def) }));
paintBg();
start();
})();
/* ── Device Animator ──────────────────────────────────────────────────────── */
(function () {
const DEVICES = [
'da-tablet', 'da-monitor-sm', 'da-monitor-lg',
'da-tv', 'da-projector', 'da-vwall'
];
const DWELL = 2500; // ms each device is shown
document.querySelectorAll('.da-stage').forEach(function (stage) {
let current = 0;
let timer = null;
// Collect the 6 device panels
const panels = DEVICES.map(function (cls) {
return stage.querySelector('.' + cls);
});
function show(idx) {
panels.forEach(function (el, i) {
if (!el) return;
el.classList.toggle('is-active', i === idx);
el.classList.remove('is-leaving');
});
}
function advance() {
const leaving = current;
current = (current + 1) % DEVICES.length;
if (panels[leaving]) panels[leaving].classList.add('is-leaving');
show(current);
setTimeout(function () {
if (panels[leaving]) panels[leaving].classList.remove('is-leaving');
}, 600);
}
function startCycle() {
if (timer) return;
timer = setInterval(advance, DWELL);
}
function stopCycle() {
clearInterval(timer);
timer = null;
}
// Honour reduced-motion preference: show first device statically
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
show(0);
return;
}
show(0);
startCycle();
// Pause when scrolled out of view to save resources
if ('IntersectionObserver' in window) {
new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
e.isIntersecting ? startCycle() : stopCycle();
});
}, { threshold: 0.2 }).observe(stage);
}
});
})();
/* ── TV Stick Plug Animation ─────────────────────────────────────────────── */
(function () {
var stages = document.querySelectorAll('[data-tv-stick-anim]');
if (!stages.length) return;
// Honour reduced-motion: show plugged-in state immediately
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
stages.forEach(function (stage) {
stage.classList.add('is-plugged');
startTsSlides(stage);
});
return;
}
if ('IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (e.isIntersecting) {
var stage = e.target;
stage.classList.add('is-animating');
// Add plugged state after slide-in completes (1.4s)
setTimeout(function () {
stage.classList.add('is-plugged');
// Start promotional slide cycling after screen glow (0.9s)
setTimeout(function () {
startTsSlides(stage);
}, 900);
}, 1400);
io.unobserve(stage);
}
});
}, { threshold: 0.3 });
stages.forEach(function (stage) { io.observe(stage); });
}
function startTsSlides(stage) {
var slides = stage.querySelectorAll('.ts-slide');
if (!slides.length) return;
var current = 0;
slides[0].classList.add('is-active');
stage.classList.add('is-playing');
setInterval(function () {
slides[current].classList.remove('is-active');
current = (current + 1) % slides.length;
slides[current].classList.add('is-active');
}, 3000);
}
})();