709 lines
26 KiB
JavaScript
709 lines
26 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 });
|
|
}
|
|
|
|
/* ── 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');
|
|
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) {
|
|
cards.forEach(c => { c.style.opacity = '0'; c.style.transform = 'translateY(24px)'; c.style.transition = 'opacity .5s ease, transform .5s ease'; });
|
|
const io = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (entry.isIntersecting) {
|
|
entry.target.style.opacity = '1';
|
|
entry.target.style.transform = 'translateY(0)';
|
|
io.unobserve(entry.target);
|
|
}
|
|
});
|
|
}, { threshold: 0.1 });
|
|
cards.forEach(c => io.observe(c));
|
|
}
|
|
});
|
|
|
|
|
|
/* -- 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');
|
|
});
|
|
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');
|
|
}, 1400);
|
|
io.unobserve(stage);
|
|
}
|
|
});
|
|
}, { threshold: 0.3 });
|
|
stages.forEach(function (stage) { io.observe(stage); });
|
|
}
|
|
})();
|