756 lines
28 KiB
JavaScript
756 lines
28 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
})();
|