/* NPP Traffic Solutions - shared site behaviour */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* ---------- intro sequence: logo holds centre, then docks onto the header logo ---------- */ /* Plays on every load of the page it's on (home page only, since introEl only exists there) - direct visit, refresh, bookmark, etc. window.__heroRevealDelay (seconds) tells home.js's hero-text reveal how long to wait so it starts right as this intro clears, instead of finishing underneath it unseen. */ (function () { var introEl = document.querySelector(".intro"); if (!introEl) return; if (reduceMotion) { introEl.classList.add("is-hidden"); return; } var introLogo = introEl.querySelector(".intro-logo"); var headerLogo = document.querySelector(".brand img"); var HOLD = 500; // logo hold time before docking var DOCK = 750; // matches the .intro-logo dock transition duration var DONE_AT = HOLD + DOCK; var HIDDEN_AT = DONE_AT + 900; document.body.classList.add("intro-locked"); requestAnimationFrame(function () { requestAnimationFrame(function () { introEl.classList.add("is-in"); }); }); setTimeout(function () { if (introLogo && headerLogo) { var startRect = introLogo.getBoundingClientRect(); var endRect = headerLogo.getBoundingClientRect(); if (startRect.width > 0 && endRect.width > 0) { var scale = endRect.width / startRect.width; var dx = (endRect.left + endRect.width / 2) - (startRect.left + startRect.width / 2); var dy = (endRect.top + endRect.height / 2) - (startRect.top + startRect.height / 2); if (isFinite(scale) && isFinite(dx) && isFinite(dy)) { introLogo.style.setProperty("--dock-x", dx + "px"); introLogo.style.setProperty("--dock-y", dy + "px"); introLogo.style.setProperty("--dock-scale", scale); } } } introEl.classList.add("is-docking"); }, HOLD); setTimeout(function () { introEl.classList.add("is-done"); document.body.classList.remove("intro-locked"); /* body was overflow:hidden/height:100% while intro-locked, which corrupts any ScrollTrigger measurements taken during that window (e.g. the window "load" refresh) -- re-measure now that real scroll is back. */ if (window.ScrollTrigger) window.ScrollTrigger.refresh(); }, DONE_AT); setTimeout(function () { introEl.classList.add("is-hidden"); }, HIDDEN_AT); window.__heroRevealDelay = DONE_AT / 1000; })(); /* ---------- header scroll state (rAF-throttled) ---------- */ var header = document.querySelector(".site-header"); var scrollTicking = false; function applyScrollState() { if (header) header.classList.toggle("is-scrolled", window.scrollY > 40); scrollTicking = false; } function onScroll() { if (!scrollTicking) { scrollTicking = true; requestAnimationFrame(applyScrollState); } } document.addEventListener("scroll", onScroll, { passive: true }); applyScrollState(); /* ---------- mobile nav toggle ---------- */ var toggle = document.querySelector(".nav-toggle"); var navLinks = document.querySelector(".nav-links"); var navDropdownItem = document.querySelector(".nav-item.has-dropdown"); function closeMobileDropdown() { if (!navDropdownItem) return; navDropdownItem.classList.remove("dropdown-open"); var t = navDropdownItem.querySelector(".nav-dropdown-toggle"); if (t) t.setAttribute("aria-expanded", "false"); navDropdownItem.querySelectorAll(".nav-subitem.has-subdropdown").forEach(function (sub) { sub.classList.remove("subdropdown-open"); var st = sub.querySelector(".nav-subdropdown-toggle"); if (st) st.setAttribute("aria-expanded", "false"); }); } if (toggle && navLinks) { toggle.addEventListener("click", function () { var open = navLinks.classList.toggle("is-open"); toggle.classList.toggle("is-open", open); toggle.setAttribute("aria-expanded", open ? "true" : "false"); document.body.style.overflow = open ? "hidden" : ""; document.body.classList.toggle("nav-open", open); if (!open) closeMobileDropdown(); }); navLinks.querySelectorAll("a").forEach(function (a) { a.addEventListener("click", function () { navLinks.classList.remove("is-open"); toggle.classList.remove("is-open"); document.body.style.overflow = ""; document.body.classList.remove("nav-open"); closeMobileDropdown(); }); }); } /* ---------- mobile nav: Services dropdown opens on tap of its arrow ---------- */ if (navDropdownItem) { var dropdownToggle = navDropdownItem.querySelector(".nav-dropdown-toggle"); if (dropdownToggle) { dropdownToggle.addEventListener("click", function (e) { e.preventDefault(); var isOpen = navDropdownItem.classList.toggle("dropdown-open"); dropdownToggle.setAttribute("aria-expanded", isOpen ? "true" : "false"); }); } } /* ---------- mobile nav: nested submenus inside the Services dropdown (e.g. Traffic Management Planning / Permit Application) open on tap of their own arrow, same pattern as the outer dropdown above ---------- */ document.querySelectorAll(".nav-subitem.has-subdropdown").forEach(function (subitem) { var subToggle = subitem.querySelector(".nav-subdropdown-toggle"); if (!subToggle) return; subToggle.addEventListener("click", function (e) { e.preventDefault(); var isOpen = subitem.classList.toggle("subdropdown-open"); subToggle.setAttribute("aria-expanded", isOpen ? "true" : "false"); }); }); /* ---------- scroll reveal (reversible: un-reveals on scroll back up) ---------- */ var revealEls = document.querySelectorAll("[data-reveal]"); if (reduceMotion) { revealEls.forEach(function (el) { el.classList.add("is-visible"); }); } else if ("IntersectionObserver" in window) { var io = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { entry.target.classList.toggle("is-visible", entry.isIntersecting); }); }, { threshold: 0.15, rootMargin: "0px 0px -60px 0px" } ); revealEls.forEach(function (el, i) { el.style.setProperty("--i", i % 6); io.observe(el); }); } else { revealEls.forEach(function (el) { el.classList.add("is-visible"); }); } /* ---------- traffic light: red -> green when in view (reversible) ---------- */ var lights = document.querySelectorAll(".traffic-light"); if ("IntersectionObserver" in window) { var lightIO = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { var el = entry.target; if (entry.isIntersecting) { setTimeout(function () { if (el.__lightVisible) el.classList.add("is-go"); }, 450); el.__lightVisible = true; } else { el.__lightVisible = false; el.classList.remove("is-go"); } }); }, { threshold: 0.6 } ); lights.forEach(function (el) { lightIO.observe(el); }); } else { lights.forEach(function (el) { el.classList.add("is-go"); }); } /* ---------- traffic cone 360 entrance (reversible) ---------- */ var cones = document.querySelectorAll(".cone-svg"); if ("IntersectionObserver" in window) { var coneIO = new IntersectionObserver( function (entries) { entries.forEach(function (entry) { entry.target.classList.toggle("is-in", entry.isIntersecting); }); }, { threshold: 0.4 } ); cones.forEach(function (el) { coneIO.observe(el); }); } else { cones.forEach(function (el) { el.classList.add("is-in"); }); } /* ---------- road-sign flip cards: tap-to-flip on touch devices ---------- */ document.querySelectorAll(".sign-card").forEach(function (card) { card.addEventListener("click", function (e) { if (window.matchMedia("(hover: none)").matches) { card.classList.toggle("is-flipped"); } }); }); /* ---------- hero background video: pause for reduced motion ---------- */ var heroVideo = document.querySelector(".hero-video"); if (heroVideo && reduceMotion) { heroVideo.removeAttribute("autoplay"); heroVideo.pause(); } /* ---------- hero video: seamless crossfade loop ---------- No transcoding tool available to build a re-encoded boomerang loop, so this runs two copies of the same clip and crossfades between them just before the first one ends, masking the hard cut back to frame one. */ if (heroVideo && !reduceMotion) { (function () { var nodeA = heroVideo; var nodeB = heroVideo.cloneNode(true); nodeA.removeAttribute("loop"); nodeB.removeAttribute("loop"); nodeB.style.opacity = "0"; nodeA.parentNode.insertBefore(nodeB, nodeA.nextSibling); var active = nodeA; var standby = nodeB; var FADE = 0.6; // seconds var crossfading = false; function tryPlay(v) { var p = v.play(); if (p && typeof p.catch === "function") p.catch(function () {}); } function onTimeUpdate(el) { if (el !== active || crossfading) return; if (!active.duration || isNaN(active.duration)) return; if (active.duration - active.currentTime <= FADE) { crossfading = true; standby.currentTime = 0; tryPlay(standby); active.style.transition = "opacity " + FADE + "s linear"; standby.style.transition = "opacity " + FADE + "s linear"; requestAnimationFrame(function () { active.style.opacity = "0"; standby.style.opacity = "1"; }); setTimeout(function () { active.pause(); var finished = active; active = standby; standby = finished; crossfading = false; }, FADE * 1000 + 60); } } nodeA.addEventListener("timeupdate", function () { onTimeUpdate(nodeA); }); nodeB.addEventListener("timeupdate", function () { onTimeUpdate(nodeB); }); })(); } /* ---------- hero cursor spotlight ---------- */ var hero = document.querySelector(".hero-spotlight"); var heroSection = document.querySelector(".hero"); if (hero && heroSection && !reduceMotion) { heroSection.addEventListener("pointermove", function (e) { var rect = heroSection.getBoundingClientRect(); var x = ((e.clientX - rect.left) / rect.width) * 100; var y = ((e.clientY - rect.top) / rect.height) * 100; hero.style.setProperty("--mx", x + "%"); hero.style.setProperty("--my", y + "%"); }); } /* ---------- hero content + tags stagger-in (after intro reveals the video) ---------- */ var heroInEls = document.querySelectorAll(".hero-in"); var heroTagEls = document.querySelectorAll(".hero-tag"); if (reduceMotion) { heroInEls.forEach(function (el) { el.classList.add("is-in"); }); heroTagEls.forEach(function (el) { el.classList.add("is-in"); }); } else { heroInEls.forEach(function (el, i) { setTimeout(function () { el.classList.add("is-in"); }, heroRevealDelay + 200 + i * 130); }); heroTagEls.forEach(function (el, i) { setTimeout(function () { el.classList.add("is-in"); }, heroRevealDelay + 200 + heroInEls.length * 130 + i * 160); }); } /* ---------- global cursor glow (subtle, whole site) ---------- */ if (!reduceMotion && !window.matchMedia("(hover: none)").matches) { var glow = document.querySelector(".cursor-glow"); if (!glow) { glow = document.createElement("div"); glow.className = "cursor-glow"; glow.setAttribute("aria-hidden", "true"); document.body.appendChild(glow); } var glowTicking = false; var lastX = 0, lastY = 0; document.addEventListener( "pointermove", function (e) { lastX = e.clientX; lastY = e.clientY; if (!glowTicking) { glowTicking = true; requestAnimationFrame(function () { glow.style.setProperty("--cx", lastX + "px"); glow.style.setProperty("--cy", lastY + "px"); glow.classList.add("is-active"); glowTicking = false; }); } }, { passive: true } ); document.addEventListener("pointerleave", function () { glow.classList.remove("is-active"); }); } /* ---------- header spotlight (cursor glow scoped to header) ---------- */ if (header && !reduceMotion && !window.matchMedia("(hover: none)").matches) { var headerSpot = header.querySelector(".header-spotlight"); if (!headerSpot) { headerSpot = document.createElement("div"); headerSpot.className = "header-spotlight"; headerSpot.setAttribute("aria-hidden", "true"); var headerContainer = header.querySelector(".container"); if (headerContainer) headerContainer.prepend(headerSpot); } var headerTicking = false; header.addEventListener( "pointermove", function (e) { var rect = header.getBoundingClientRect(); var hx = e.clientX - rect.left; var hy = e.clientY - rect.top; if (!headerTicking) { headerTicking = true; requestAnimationFrame(function () { headerSpot.style.setProperty("--hx", hx + "px"); headerSpot.style.setProperty("--hy", hy + "px"); headerTicking = false; }); } }, { passive: true } ); } /* ---------- FAQ accordion ---------- */ document.querySelectorAll(".faq-item").forEach(function (item) { var q = item.querySelector(".faq-q"); var a = item.querySelector(".faq-a"); if (!q || !a) return; q.addEventListener("click", function () { var isOpen = item.classList.contains("is-open"); item.parentElement.querySelectorAll(".faq-item.is-open").forEach(function (other) { if (other !== item) { other.classList.remove("is-open"); other.querySelector(".faq-a").style.maxHeight = null; } }); if (isOpen) { item.classList.remove("is-open"); a.style.maxHeight = null; } else { item.classList.add("is-open"); a.style.maxHeight = a.scrollHeight + "px"; } }); }); /* ---------- native forms: client-side handling ---------- Validates, shows a confirmation state, and resets. The contact form also posts a JSON payload to the GHL inbound webhook; other [data-npp-form] forms (e.g. request-a-quote) are unaffected until they get their own workflow wired up. */ var CONTACT_WEBHOOK_URL = "https://services.leadconnectorhq.com/hooks/W1AHh7ZFkwu5pB345rqR/webhook-trigger/a76a8d71-3b66-4ece-8550-89b3cfe026f4"; document.querySelectorAll("[data-npp-form]").forEach(function (form) { var fileInput = form.querySelector("input[type=\"file\"][data-max-size]"); form.addEventListener("submit", function (e) { e.preventDefault(); if (!form.reportValidity()) return; if (fileInput && fileInput.files.length) { var maxSize = parseInt(fileInput.getAttribute("data-max-size"), 10); var tooBig = Array.prototype.some.call(fileInput.files, function (f) { return f.size > maxSize; }); var fileErrorEl = form.querySelector("[data-file-error]"); if (tooBig) { if (fileErrorEl) fileErrorEl.hidden = false; fileInput.focus(); return; } if (fileErrorEl) fileErrorEl.hidden = true; } var honeypot = form.querySelector('input[name="website"]'); if (honeypot && honeypot.value) return; // likely a bot, silently drop if (form.getAttribute("data-success-target") === "contact-success") { var fullName = (form.querySelector('[name="name"]') || {}).value || ""; var nameParts = fullName.trim().split(/\s+/).filter(Boolean); var firstName = nameParts.shift() || ""; var lastName = nameParts.join(" "); var payload = { first_name: firstName, last_name: lastName, email: (form.querySelector('[name="email"]') || {}).value || "", phone: (form.querySelector('[name="phone"]') || {}).value || "", service_requested: (form.querySelector('[name="service"]') || {}).value || "", message: (form.querySelector('[name="message"]') || {}).value || "", source: "NPP Website - Contact Form", page_url: window.location.href }; fetch(CONTACT_WEBHOOK_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }).catch(function (err) { console.error("Contact form webhook failed:", err); }); } var successEl = document.getElementById(form.getAttribute("data-success-target") || ""); form.classList.add("is-hidden"); if (successEl) { successEl.classList.add("is-visible"); successEl.setAttribute("tabindex", "-1"); /* focus() alone only scrolls the minimum amount needed to bring the element into view (often just nudging it onto the edge of the screen), which is why the confirmation used to land off-centre with a huge gap of now-empty space where the form used to be. scrollIntoView(center) fixes that - but only once the browser has actually laid out the swap from form to success message; called in the same tick as the class changes above, it measures the old (pre-swap) layout and undershoots. One rAF is enough to wait for that reflow. */ requestAnimationFrame(function () { successEl.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" }); successEl.focus({ preventScroll: true }); }); } form.reset(); }); }); /* ---------- contact popup (business card) ---------- */ var overlay = document.querySelector(".contact-overlay"); if (overlay) { var openers = document.querySelectorAll("[data-open-contact]"); var closers = overlay.querySelectorAll("[data-close-contact]"); var lastFocused = null; function openOverlay(e) { if (e) e.preventDefault(); lastFocused = document.activeElement; overlay.classList.add("is-open"); overlay.setAttribute("aria-hidden", "false"); document.body.style.overflow = "hidden"; var closeBtn = overlay.querySelector(".close-btn"); if (closeBtn) closeBtn.focus(); } function closeOverlay() { overlay.classList.remove("is-open"); overlay.setAttribute("aria-hidden", "true"); document.body.style.overflow = ""; if (lastFocused) lastFocused.focus(); } openers.forEach(function (btn) { btn.addEventListener("click", openOverlay); }); closers.forEach(function (btn) { btn.addEventListener("click", closeOverlay); }); overlay.addEventListener("click", function (e) { if (e.target === overlay) closeOverlay(); }); document.addEventListener("keydown", function (e) { if (e.key === "Escape" && overlay.classList.contains("is-open")) closeOverlay(); }); if (window.location.hash === "#open-contact") openOverlay(); } })(); /* NPP Traffic Solutions - homepage motion system (GSAP + ScrollTrigger) */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; var hasGSAP = typeof window.gsap !== "undefined" && typeof window.ScrollTrigger !== "undefined"; if (!hasGSAP) { /* Library failed to load (offline / CDN blocked) - reveal everything statically, no motion. */ document.querySelectorAll("[data-hero], .why-v2-item, .why-v2-panel, .testi-card, [data-cta-inner]") .forEach(function (el) { el.style.opacity = "1"; el.style.transform = "none"; el.style.clipPath = "none"; }); return; } gsap.registerPlugin(ScrollTrigger); /* ======================================================================== HERO - reveal sequence (waits for the logo-dock intro to clear) ======================================================================== */ (function heroSequence() { /* main.js's intro sequence (logo hold + dock) plays on every load of this page, not just a refresh - it sets window.__heroRevealDelay (seconds) to the exact moment it clears. Starting the hero-text reveal at that same moment, rather than at 0, keeps it visible instead of finishing underneath the still-covering intro. */ var startDelay = window.__heroRevealDelay || 0; var h1 = document.querySelector('[data-hero="h1"]'); var lead = document.querySelector('[data-hero="lead"]'); var cta1 = document.querySelector('[data-hero="cta1"]'); var cta2 = document.querySelector('[data-hero="cta2"]'); var trustItems = document.querySelectorAll(".hero-v2-trust-item"); if (reduceMotion) { [h1, lead, cta1, cta2].forEach(function (el) { if (el) { el.style.opacity = "1"; el.style.transform = "none"; } }); trustItems.forEach(function (el) { el.style.clipPath = "inset(0 0 0 0)"; }); return; } var tl = gsap.timeline({ delay: startDelay, defaults: { ease: "power3.out" } }); if (h1) tl.to(h1, { opacity: 1, y: 0, duration: 1.1, ease: "power2.out" }, 0); if (lead) tl.to(lead, { opacity: 1, y: 0, duration: 0.9 }, 0.35); if (cta1) tl.to(cta1, { opacity: 1, x: 0, duration: 0.85, ease: "back.out(1.6)" }, 0.6); if (cta2) tl.to(cta2, { opacity: 1, x: 0, duration: 0.85, ease: "back.out(1.6)" }, 0.67); if (trustItems.length) { tl.to(trustItems, { clipPath: "inset(0 0% 0 0)", stagger: 0.14, duration: 0.7, ease: "power2.inOut" }, 0.9); } })(); /* ======================================================================== FULL-BLEED PHOTO SECTIONS - every background photo (hero, industries, why choose, cta) drifts slower than the page scrolls, via a scrubbed ScrollTrigger per image (no pin, no smooth-scroll lib - just a transform tied to scroll progress through that image's own section). ======================================================================== */ (function scrollParallax() { if (reduceMotion) return; var imgs = document.querySelectorAll("[data-parallax-img]"); imgs.forEach(function (img) { var section = img.closest("section"); if (!section) return; var isHero = img.closest(".hero-v2"); gsap.fromTo(img, { yPercent: isHero ? -8 : -16 }, { yPercent: isHero ? 8 : 16, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: true } } ); }); })(); /* ======================================================================== OPERATIONAL SHOWCASE - numbered slide + arrow nav ======================================================================== */ (function showcase() { var section = document.querySelector(".showcase"); var jobs = document.querySelectorAll("[data-showcase-job]"); var imgs = document.querySelectorAll("[data-showcase-img]"); var prevBtn = document.querySelector("[data-showcase-prev]"); var nextBtn = document.querySelector("[data-showcase-next]"); var currentEl = document.querySelector("[data-showcase-current]"); var totalEl = document.querySelector("[data-showcase-total]"); if (!section || !jobs.length) return; var count = jobs.length; var index = 0; function pad(n) { return String(n).padStart(2, "0"); } function render() { jobs.forEach(function (j) { j.classList.toggle("is-active", +j.dataset.showcaseJob === index); }); imgs.forEach(function (im) { im.classList.toggle("is-active", +im.dataset.showcaseImg === index); }); if (currentEl) currentEl.textContent = pad(index + 1); } function go(delta) { index = (index + delta + count) % count; render(); } if (totalEl) totalEl.textContent = pad(count); render(); if (prevBtn) prevBtn.addEventListener("click", function () { go(-1); }); if (nextBtn) nextBtn.addEventListener("click", function () { go(1); }); })(); /* ======================================================================== WHY CHOOSE NPP - vertical stagger + icon micro-bounce ======================================================================== */ (function whyChoose() { var items = document.querySelectorAll("[data-why-list] .why-v2-item"); if (!items.length) return; if (reduceMotion) { items.forEach(function (el) { el.style.opacity = "1"; el.style.transform = "none"; }); return; } var tl = gsap.timeline({ scrollTrigger: { trigger: "[data-why-list]", start: "top 82%", toggleActions: "play none none reverse" } }); items.forEach(function (item, i) { var icon = item.querySelector(".why-v2-ico"); tl.to(item, { opacity: 1, y: 0, duration: 0.6, ease: "power2.out" }, i * 0.1); if (icon) tl.to(icon, { scale: 1.08, duration: 0.22, ease: "power1.out" }, i * 0.1 + 0.3) .to(icon, { scale: 1, duration: 0.22, ease: "power1.inOut" }, i * 0.1 + 0.52); }); })(); /* ======================================================================== TESTIMONIALS - staggered slide+fade + hover parallax tilt ======================================================================== */ (function testimonials() { var cards = document.querySelectorAll("[data-testi-card]"); if (!cards.length) return; if (reduceMotion) { cards.forEach(function (c) { c.style.opacity = "1"; c.style.transform = "none"; }); } else { gsap.to(cards, { opacity: 1, y: 0, duration: 0.8, stagger: 0.15, ease: "power2.out", scrollTrigger: { trigger: "[data-testi-grid]", start: "top 85%", toggleActions: "play none none reverse" } }); if (!window.matchMedia("(hover: none)").matches) { cards.forEach(function (card) { var rotX = gsap.quickTo(card, "rotationX", { duration: 0.4, ease: "power2.out" }); var rotY = gsap.quickTo(card, "rotationY", { duration: 0.4, ease: "power2.out" }); card.style.transformPerspective = "700px"; card.addEventListener("pointermove", function (e) { var rect = card.getBoundingClientRect(); var px = (e.clientX - rect.left) / rect.width - 0.5; var py = (e.clientY - rect.top) / rect.height - 0.5; rotY(px * 8); rotX(py * -8); }); card.addEventListener("pointerleave", function () { rotX(0); rotY(0); }); }); } } })(); /* ======================================================================== CTA - confident reveal ======================================================================== */ (function ctaReveal() { var inner = document.querySelector("[data-cta-inner]"); if (!inner) return; if (reduceMotion) { inner.style.opacity = "1"; inner.style.transform = "none"; return; } gsap.to(inner, { opacity: 1, y: 0, scale: 1, duration: 0.9, ease: "power3.out", scrollTrigger: { trigger: inner, start: "top 85%", toggleActions: "play none none reverse" } }); })(); /* Refresh ScrollTrigger once images/fonts settle (layout shifts affect pin ranges). */ window.addEventListener("load", function () { ScrollTrigger.refresh(); }); })(); /* NPP Traffic Solutions - About Us page motion (GSAP + ScrollTrigger) */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; var hasGSAP = typeof window.gsap !== "undefined" && typeof window.ScrollTrigger !== "undefined"; if (!hasGSAP) return; gsap.registerPlugin(ScrollTrigger); /* Full-bleed photo sections drift slower than the page scrolls. */ (function scrollParallax() { if (reduceMotion) return; document.querySelectorAll("[data-parallax-img]").forEach(function (img) { var section = img.closest("section"); if (!section) return; gsap.fromTo(img, { yPercent: -16 }, { yPercent: 16, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: true } } ); }); })(); /* Capability items - .why-v2-item ships permanently opacity:0 in home.css, normally revealed by home.js's bespoke timeline (not loaded here). */ (function capabilityReveal() { var items = document.querySelectorAll("[data-why-list] .why-v2-item"); if (!items.length) return; if (reduceMotion) { items.forEach(function (el) { el.style.opacity = "1"; el.style.transform = "none"; }); return; } var tl = gsap.timeline({ scrollTrigger: { trigger: "[data-why-list]", start: "top 82%", toggleActions: "play none none reverse" } }); items.forEach(function (item, i) { var icon = item.querySelector(".why-v2-ico"); tl.to(item, { opacity: 1, y: 0, duration: 0.6, ease: "power2.out" }, i * 0.1); if (icon) tl.to(icon, { scale: 1.08, duration: 0.22, ease: "power1.out" }, i * 0.1 + 0.3) .to(icon, { scale: 1, duration: 0.22, ease: "power1.inOut" }, i * 0.1 + 0.52); }); })(); /* CTA reveal - .cta-v2-inner ships permanently opacity:0 in home.css (normally driven by home.js's GSAP timeline, which isn't loaded here). */ (function ctaReveal() { var inner = document.querySelector("[data-cta-inner]"); if (!inner) return; if (reduceMotion) { inner.style.opacity = "1"; inner.style.transform = "none"; return; } gsap.to(inner, { opacity: 1, y: 0, scale: 1, duration: 0.9, ease: "power3.out", scrollTrigger: { trigger: inner, start: "top 85%", toggleActions: "play none none reverse" } }); })(); window.addEventListener("load", function () { ScrollTrigger.refresh(); }); /* Defensive: if the page happened to load in a background/hidden tab, the very first measurement can be taken against a zeroed-out viewport. Re-measure once it's actually visible. */ document.addEventListener("visibilitychange", function () { if (!document.hidden) ScrollTrigger.refresh(); }); })(); /* NPP Traffic Solutions - Services page motion (GSAP + ScrollTrigger) */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* Cursor spotlight on the service feature cards - doesn't need GSAP, so it runs even if the CDN scripts fail to load. */ (function cardSpotlight() { if (reduceMotion || window.matchMedia("(hover: none)").matches) return; document.querySelectorAll(".service-feature-card").forEach(function (card) { card.addEventListener("pointermove", function (e) { var rect = card.getBoundingClientRect(); card.style.setProperty("--mx", ((e.clientX - rect.left) / rect.width) * 100 + "%"); card.style.setProperty("--my", ((e.clientY - rect.top) / rect.height) * 100 + "%"); }); }); })(); var hasGSAP = typeof window.gsap !== "undefined" && typeof window.ScrollTrigger !== "undefined"; if (!hasGSAP) return; gsap.registerPlugin(ScrollTrigger); /* Full-bleed photo sections drift slower than the page scrolls. */ (function scrollParallax() { if (reduceMotion) return; document.querySelectorAll("[data-parallax-img]").forEach(function (img) { var section = img.closest("section"); if (!section) return; gsap.fromTo(img, { yPercent: -16 }, { yPercent: 16, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: true } } ); }); })(); /* Service feature cards + equipment cards: reveal on scroll (reversible), reusing the same [data-reveal] system as main.js but with GSAP stagger so the cadence matches the homepage's Why Choose section. */ (function staggerReveal(selector, itemSelector) { document.querySelectorAll(selector).forEach(function (group) { var items = group.querySelectorAll(itemSelector); if (!items.length) return; if (reduceMotion) { items.forEach(function (el) { el.style.opacity = "1"; el.style.transform = "none"; }); return; } gsap.to(items, { opacity: 1, y: 0, duration: 0.7, stagger: 0.1, ease: "power2.out", scrollTrigger: { trigger: group, start: "top 85%", toggleActions: "play none none reverse" } }); }); })(".service-feature-grid", ".service-feature-card"); /* CTA reveal - .cta-v2-inner ships permanently opacity:0 in home.css (normally driven by home.js's GSAP timeline, which isn't loaded here), so it needs its own reveal or it would never become visible. */ (function ctaReveal() { var inner = document.querySelector("[data-cta-inner]"); if (!inner) return; if (reduceMotion) { inner.style.opacity = "1"; inner.style.transform = "none"; return; } gsap.to(inner, { opacity: 1, y: 0, scale: 1, duration: 0.9, ease: "power3.out", scrollTrigger: { trigger: inner, start: "top 85%", toggleActions: "play none none reverse" } }); })(); window.addEventListener("load", function () { ScrollTrigger.refresh(); }); })(); /* NPP Traffic Solutions - Previous Jobs page motion (GSAP + ScrollTrigger) */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* Category filter pills - show/hide project rows, no GSAP required. */ (function categoryFilters() { var bar = document.querySelector("[data-pj-filters]") || document.querySelector(".pj-filters"); if (!bar) return; var buttons = bar.querySelectorAll(".pj-filter"); var rows = document.querySelectorAll(".pj-row"); function markFirstVisible() { var found = false; rows.forEach(function (row) { var isFirst = !found && !row.classList.contains("is-filtered-out"); row.classList.toggle("pj-is-first-visible", isFirst); if (isFirst) found = true; }); } bar.addEventListener("click", function (e) { var btn = e.target.closest(".pj-filter"); if (!btn) return; buttons.forEach(function (b) { b.classList.remove("is-active"); }); btn.classList.add("is-active"); var filter = btn.getAttribute("data-filter"); rows.forEach(function (row) { var categories = (row.getAttribute("data-category") || "").split(/\s+/); var match = filter === "all" || categories.indexOf(filter) !== -1; row.classList.toggle("is-filtered-out", !match); }); markFirstVisible(); if (window.ScrollTrigger) { requestAnimationFrame(function () { ScrollTrigger.refresh(); }); } }); markFirstVisible(); })(); var hasGSAP = typeof window.gsap !== "undefined" && typeof window.ScrollTrigger !== "undefined"; if (!hasGSAP) return; gsap.registerPlugin(ScrollTrigger); /* Full-bleed photo sections drift slower than the page scrolls. */ (function scrollParallax() { if (reduceMotion) return; document.querySelectorAll("[data-parallax-img]").forEach(function (img) { var section = img.closest("section"); if (!section) return; gsap.fromTo(img, { yPercent: -16 }, { yPercent: 16, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: true } } ); }); })(); /* Project list: each row's photo starts smaller and grows to full size as the row scrolls up through the lower half of the viewport. */ (function growPhotoOnScroll() { document.querySelectorAll(".pj-row-media-inner").forEach(function (media) { if (reduceMotion) { gsap.set(media, { scale: 1 }); return; } gsap.fromTo(media, { scale: 0.82 }, { scale: 1, ease: "none", scrollTrigger: { trigger: media, start: "top 90%", end: "top 30%", scrub: 0.4 } } ); }); })(); /* CTA reveal - .cta-v2-inner ships permanently opacity:0 in home.css (normally driven by home.js's GSAP timeline, which isn't loaded here). */ (function ctaReveal() { var inner = document.querySelector("[data-cta-inner]"); if (!inner) return; if (reduceMotion) { inner.style.opacity = "1"; inner.style.transform = "none"; return; } gsap.to(inner, { opacity: 1, y: 0, scale: 1, duration: 0.9, ease: "power3.out", scrollTrigger: { trigger: inner, start: "top 85%", toggleActions: "play none none reverse" } }); })(); window.addEventListener("load", function () { ScrollTrigger.refresh(); }); /* Defensive: if the page happened to load in a background/hidden tab, the very first measurement can be taken against a zeroed-out viewport. Re-measure once it's actually visible. */ document.addEventListener("visibilitychange", function () { if (!document.hidden) ScrollTrigger.refresh(); }); })(); /* NPP Traffic Solutions - Contact Us page motion (GSAP + ScrollTrigger) */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* Form/info panel reveal: fires on load, not on scroll, so it's guaranteed visible the moment you land on the page. */ (function revealContactGrid() { var grid = document.querySelector(".contact-reveal"); if (!grid) return; if (reduceMotion) { grid.classList.add("is-visible"); return; } setTimeout(function () { grid.classList.add("is-visible"); }, 50); })(); var hasGSAP = typeof window.gsap !== "undefined" && typeof window.ScrollTrigger !== "undefined"; if (!hasGSAP) return; gsap.registerPlugin(ScrollTrigger); /* Hero photo drifts slower than the page scrolls. */ (function scrollParallax() { if (reduceMotion) return; document.querySelectorAll("[data-parallax-img]").forEach(function (img) { var section = img.closest("section"); if (!section) return; gsap.fromTo(img, { yPercent: -12 }, { yPercent: 12, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: true } } ); }); })(); window.addEventListener("load", function () { ScrollTrigger.refresh(); }); document.addEventListener("visibilitychange", function () { if (!document.hidden) ScrollTrigger.refresh(); }); })(); /* NPP Traffic Solutions - Request a Quote page motion (GSAP + ScrollTrigger) */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; /* "What happens next" / form panel reveal: fires on load, not on scroll/ScrollTrigger, so it's guaranteed visible the moment you land on the page regardless of viewport height or trigger timing. */ (function panelReveal() { var panel = document.querySelector(".quote-reveal"); if (!panel) return; if (reduceMotion) { panel.classList.add("is-visible"); return; } setTimeout(function () { panel.classList.add("is-visible"); }, 50); })(); /* The embedded quote form's widget script resizes its own iframe once it measures the real content, in several steps (a tiny placeholder height, then the true final one) - CSS can only ever guess at a static number, which either clips the form (guess too small) or leaves a dead gap below the submit button (guess too large, as the form is usually shorter than the desktop reference height it ships with). Mirroring the iframe's own reported height onto .form-panel as it changes keeps the card exactly as tall as the real form, however many steps the resize goes through. */ (function syncQuoteFormHeight() { var panel = document.querySelector(".form-panel"); if (!panel) return; var lastApplied = 0; function applyHeight() { var iframe = panel.querySelector("iframe"); if (!iframe) return; var h = parseInt(iframe.style.height, 10); if (h && h > 200 && h !== lastApplied) { /* The widget swaps the whole multi-field form for a short "submitted!" message inside the same iframe once it's successful - we can't reach into that cross-origin content to centre it ourselves, but this height drop is our only signal that it just happened, so centre the wrapper we do control around it instead. A shrink to under half the previous (established) height is that transition; the very first measurement is always a grow from 0 and never counts. */ if (lastApplied > 500 && h < lastApplied * 0.5) { setTimeout(function () { panel.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "center" }); }, 50); } lastApplied = h; panel.style.minHeight = h + "px"; } } function watchIframe() { var iframe = panel.querySelector("iframe"); if (!iframe) return; new MutationObserver(applyHeight).observe(iframe, { attributes: true, attributeFilter: ["style"] }); applyHeight(); } watchIframe(); /* The widget script rebuilds its own wrapper/iframe on load, so also watch the panel itself for that swap and re-attach to the new node. */ new MutationObserver(watchIframe).observe(panel, { childList: true, subtree: true }); })(); var hasGSAP = typeof window.gsap !== "undefined" && typeof window.ScrollTrigger !== "undefined"; if (!hasGSAP) return; gsap.registerPlugin(ScrollTrigger); /* Hero photo drifts slower than the page scrolls. */ (function scrollParallax() { if (reduceMotion) return; document.querySelectorAll("[data-parallax-img]").forEach(function (img) { var section = img.closest("section"); if (!section) return; gsap.fromTo(img, { yPercent: -12 }, { yPercent: 12, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: true } } ); }); })(); window.addEventListener("load", function () { ScrollTrigger.refresh(); }); document.addEventListener("visibilitychange", function () { if (!document.hidden) ScrollTrigger.refresh(); }); })(); /* NPP Traffic Solutions - Legal pages motion (GSAP + ScrollTrigger) */ (function () { "use strict"; var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; var hasGSAP = typeof window.gsap !== "undefined" && typeof window.ScrollTrigger !== "undefined"; /* Body text reveal: each paragraph/heading fades in once as it scrolls into view, matching the rest of the site's [data-reveal] pattern (main.js) rather than GSAP/ScrollTrigger - a per-element ScrollTrigger snapshots its trigger position once, synchronously, the moment it's created; on a long page that runs before webfonts or the hero photo finish settling the layout, every position can measure wrong and everything ends up already "past" its trigger, firing at once with no visible animation. IntersectionObserver has no such snapshot - it reacts to the real, current layout on every frame, so it can't go stale like that. */ (function revealFlowChildren() { var items = document.querySelectorAll(".flow > *"); if (!items.length) return; if (reduceMotion || !("IntersectionObserver" in window)) { items.forEach(function (el) { el.classList.add("is-visible"); }); return; } var flowIO = new IntersectionObserver(function (entries, observer) { entries.forEach(function (entry) { if (entry.isIntersecting) { entry.target.classList.add("is-visible"); observer.unobserve(entry.target); } }); }, { threshold: 0.1, rootMargin: "0px 0px -60px 0px" }); items.forEach(function (el) { flowIO.observe(el); }); })(); if (!hasGSAP) return; /* Hero photo drifts slower than the page scrolls. */ (function scrollParallax() { if (reduceMotion) return; document.querySelectorAll("[data-parallax-img]").forEach(function (img) { var section = img.closest("section"); if (!section) return; gsap.fromTo(img, { yPercent: -12 }, { yPercent: 12, ease: "none", scrollTrigger: { trigger: section, start: "top bottom", end: "bottom top", scrub: true } } ); }); })(); window.addEventListener("load", function () { ScrollTrigger.refresh(); }); document.addEventListener("visibilitychange", function () { if (!document.hidden) ScrollTrigger.refresh(); }); })();