/* ============================================================ PixelEstate β€” interactions (GSAP 3.15 + ScrollTrigger, Lenis 1.3) Content is visible without JS; this file only enhances. ============================================================ */ (function () { "use strict"; const root = document.documentElement; root.classList.add("js"); const CFG = window.PIXELESTATE || {}; const LQIP = window.PE_LQIP || {}; const REDUCED = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const TOUCH = window.matchMedia("(hover: none), (pointer: coarse)").matches; const $ = (s, c) => (c || document).querySelector(s); const $$ = (s, c) => Array.from((c || document).querySelectorAll(s)); gsap.registerPlugin(ScrollTrigger); /* ---------- Lenis ---------- */ let lenis = null; if (!REDUCED) { lenis = new Lenis({ duration: 1.1, lerp: 0.1, smoothWheel: true }); lenis.on("scroll", ScrollTrigger.update); gsap.ticker.add((t) => lenis.raf(t * 1000)); gsap.ticker.lagSmoothing(0); } const scrollToEl = (t) => lenis ? lenis.scrollTo(t, { offset: -64, duration: 1.1 }) : t.scrollIntoView({ behavior: REDUCED ? "auto" : "smooth", block: "start" }); /* ---------- blur-up ---------- */ function blurUp(scope) { $$("img", scope).forEach((img) => { const m = (img.getAttribute("src") || "").match(/\/(hero|g\d+|svc\d+)\.jpg$/); const key = m && m[1]; if (key && LQIP[key] && !img.dataset.bu) { img.dataset.bu = "1"; const holder = img.closest(".gal-item, .bg, .hero-media, .phone .screen") || img.parentElement; holder.style.backgroundImage = `url(${LQIP[key]})`; holder.style.backgroundSize = "cover"; holder.style.backgroundPosition = "center"; img.style.opacity = "0"; img.style.transition = "opacity .8s ease"; const show = () => (img.style.opacity = "1"); img.complete && img.naturalWidth ? show() : img.addEventListener("load", show, { once: true }); } }); } /* ---------- reveals ---------- */ function reveals() { if (REDUCED) return; $$(".reveal").forEach((el) => gsap.to(el, { opacity: 1, y: 0, filter: "blur(0px)", duration: 1.1, ease: "power4.out", scrollTrigger: { trigger: el, start: "top 90%" } })); const lines = $$(".statement .line i"); if (lines.length) { gsap.set(lines, { yPercent: 110, opacity: 0 }); gsap.to(lines, { yPercent: 0, opacity: 1, duration: 1.2, ease: "power4.out", stagger: 0.12, scrollTrigger: { trigger: "#statement", start: "top 72%" } }); } } /* ---------- brokerage logos ---------- */ function brokerages() { const host = $("#logoGrid"); const list = CFG.brokerages || []; // duplicate the set so the marquee can loop seamlessly const one = list.map((b) => `${b.t}`).join(""); host.innerHTML = one + one; if (REDUCED) { host.style.justifyContent = "center"; host.style.flexWrap = "wrap"; return; } // continuous running marquee let x = 0, half = 0, last = performance.now(), paused = false; const speed = 42; // px per second const measure = () => { half = host.scrollWidth / 2; }; measure(); window.addEventListener("resize", measure); host.addEventListener("pointerenter", () => (paused = true)); host.addEventListener("pointerleave", () => (paused = false)); function tick(now) { const dt = (now - last) / 1000; last = now; if (!paused && half) { x -= speed * dt; if (-x >= half) x += half; host.style.transform = `translate3d(${x}px,0,0)`; } requestAnimationFrame(tick); } requestAnimationFrame(tick); } /* ---------- HERO: real video scrub + fallback ---------- */ function hero() { const heroImg = $("#heroImg"); const overlay = $("#heroOverlay"); const ind = $("#scrollInd"); const media = $("#heroMedia"); const vsrc = CFG.assets && CFG.assets.heroVideo; const vwebm = CFG.assets && CFG.assets.heroVideoWebm; let video = null, scrub = false; const makeVideo = () => { const v = document.createElement("video"); v.muted = true; v.playsInline = true; v.preload = "auto"; v.setAttribute("aria-hidden", "true"); if (CFG.assets.heroPoster) v.poster = CFG.assets.heroPoster; // WebM first only as a fallback source; H.264 stays primary for universal support const add = (src, type) => { const s = document.createElement("source"); s.src = src; s.type = type; v.appendChild(s); }; if (vsrc) add(vsrc, "video/mp4"); if (vwebm) add(vwebm, "video/webm"); return v; }; if (vsrc && !TOUCH && !REDUCED) { // desktop: scrub the video video = makeVideo(); media.replaceChild(video, heroImg); scrub = true; video.load(); } else if (vsrc && (TOUCH || REDUCED)) { // touch/reduced: autoplay loop video = makeVideo(); video.loop = true; video.autoplay = true; media.replaceChild(video, heroImg); video.play().catch(() => {}); } if (REDUCED) return; const distance = TOUCH ? "+=140%" : "+=220%"; // ~300vh+ pinned const target = video || heroImg; gsap.timeline({ scrollTrigger: { trigger: "#hero", start: "top top", end: distance, pin: "#heroPin", scrub: 0.6, onUpdate: (self) => { if (scrub && video && video.readyState >= 1 && video.duration) { const t = self.progress * (video.duration - 0.05); if (Math.abs(video.currentTime - t) > 0.01) video.currentTime = t; } }, }, }) .to(target, { scale: scrub ? 1.04 : 1.22, ease: "none" }, 0) .to(overlay, { y: -90, opacity: 0, ease: "power2.in" }, 0) .to(ind, { opacity: 0, ease: "power1.in" }, 0); } /* ---------- GALLERY: square grid + parallax + lightbox ---------- */ function gallery() { const grid = $("#galGrid"); const items = CFG.gallery || []; grid.innerHTML = items.map((g, i) => `
${g.t}
${g.t}${g.s}
`).join(""); blurUp(grid); if (!REDUCED) { gsap.from($$(".gal-item", grid), { opacity: 0, y: 26, duration: 0.9, ease: "power3.out", stagger: 0.05, scrollTrigger: { trigger: grid, start: "top 86%" } }); // scroll-linked parallax on each photo $$(".gal-item img", grid).forEach((img) => { gsap.fromTo(img, { yPercent: -2.4 }, { yPercent: 2.4, ease: "none", scrollTrigger: { trigger: img.parentElement, start: "top bottom", end: "bottom top", scrub: true } }); }); } // lightbox const lb = $("#lightbox"), lbImg = $("#lbImg"); let idx = 0; const open = (i) => { idx = (i + items.length) % items.length; lbImg.src = `assets/img/${items[idx].k}.jpg`; lbImg.alt = items[idx].t; lb.classList.add("open"); lb.setAttribute("aria-hidden", "false"); }; const close = () => { lb.classList.remove("open"); lb.setAttribute("aria-hidden", "true"); }; grid.addEventListener("click", (e) => { const it = e.target.closest(".gal-item"); if (it) open(+it.dataset.i); }); $("#lbClose").addEventListener("click", close); $("#lbPrev").addEventListener("click", (e) => { e.stopPropagation(); open(idx - 1); }); $("#lbNext").addEventListener("click", (e) => { e.stopPropagation(); open(idx + 1); }); lb.addEventListener("click", (e) => { if (e.target === lb) close(); }); document.addEventListener("keydown", (e) => { if (!lb.classList.contains("open")) return; if (e.key === "Escape") close(); else if (e.key === "ArrowRight") open(idx + 1); else if (e.key === "ArrowLeft") open(idx - 1); }); } /* ---------- reusable coverflow ---------- */ function makeCoverflow(cf, track, onCenter, startIndex) { const items = () => Array.from(track.children); function nearest() { const r = cf.getBoundingClientRect(), cx = r.left + r.width / 2; let b = 0, bd = 1e9; items().forEach((f, i) => { const fr = f.getBoundingClientRect(); const d = Math.abs(fr.left + fr.width / 2 - cx); if (d < bd) { bd = d; b = i; } }); return b; } function mark() { const b = nearest(); items().forEach((f, i) => f.classList.toggle("is-center", i === b)); onCenter && onCenter(b); return b; } function to(i) { const els = items(); i = Math.max(0, Math.min(els.length - 1, i)); const f = els[i], fr = f.getBoundingClientRect(), cr = cf.getBoundingClientRect(); cf.scrollTo({ left: cf.scrollLeft + (fr.left - cr.left) - (cf.clientWidth - fr.width) / 2, behavior: "smooth" }); } let raf = 0; cf.addEventListener("scroll", () => { if (!raf) raf = requestAnimationFrame(() => { mark(); raf = 0; }); }, { passive: true }); let down = false, sx = 0, sy = 0, sl = 0, moved = false, pid = null, suppressClick = false; cf.addEventListener("pointerdown", (e) => { if (e.button && e.button !== 0) return; down = true; moved = false; sx = e.clientX; sy = e.clientY; sl = cf.scrollLeft; pid = e.pointerId; cf.classList.add("drag"); try { cf.setPointerCapture(e.pointerId); } catch (_) {} }); cf.addEventListener("pointermove", (e) => { if (!down) return; const dx = e.clientX - sx; if (Math.abs(dx) > 10 || Math.abs(e.clientY - sy) > 10) moved = true; cf.scrollLeft = sl - dx; }); const end = (e) => { if (!down) return; down = false; cf.classList.remove("drag"); try { cf.releasePointerCapture(pid); } catch (_) {} if (!moved) { // TAP: bring the tapped card to center (handled here, not via click β€” reliable with real mouse) const el = document.elementFromPoint(e.clientX, e.clientY); const card = el && el.closest(".pkg"); if (card && !card.classList.contains("is-center")) { suppressClick = true; to(items().indexOf(card)); } } }; cf.addEventListener("pointerup", end); cf.addEventListener("pointercancel", end); // suppress the click that follows a drag OR a tap-to-center (so buttons on a not-yet-centered card don't fire) cf.addEventListener("click", (e) => { if (moved || suppressClick) { e.preventDefault(); e.stopPropagation(); suppressClick = false; } }, true); window.addEventListener("resize", mark); requestAnimationFrame(() => { to(startIndex || 0); setTimeout(mark, 80); }); return { mark, to, nearest }; } /* ---------- PROCESS: step-to-step fill ---------- */ function process() { const fill = $("#tlFill"); const steps = $$("#timeline [data-step]"); const vertical = () => window.matchMedia("(max-width:760px)").matches; ScrollTrigger.create({ trigger: "#timeline", start: "top 78%", end: "bottom 32%", scrub: 0.7, onUpdate: (self) => { const p = REDUCED ? 1 : self.progress; fill.style[vertical() ? "height" : "width"] = (p * 100).toFixed(1) + "%"; const n = steps.length; steps.forEach((s, i) => s.classList.toggle("on", p >= i / (n - 1) - 0.02)); }, }); } /* ---------- REELS ---------- */ function reels() { const make = (sel, vsrc, poster) => { const host = $(sel); if (vsrc) { const v = document.createElement("video"); v.src = vsrc; v.muted = true; v.loop = true; v.autoplay = true; v.playsInline = true; v.poster = poster || ""; const btn = document.createElement("button"); btn.className = "sound"; btn.textContent = "πŸ”‡"; btn.setAttribute("aria-label", "Unmute"); btn.addEventListener("click", () => { v.muted = !v.muted; btn.textContent = v.muted ? "πŸ”‡" : "πŸ”Š"; if (!v.muted) v.play(); }); host.append(v, btn); v.play().catch(() => {}); } else { host.innerHTML = `Reel preview
β–Ά
Drop reel1.mp4 / reel2.mp4
into /assets/video to go live
`; } }; make("#reelA", CFG.assets.reel1, CFG.assets.reel1Poster); make("#reelB", CFG.assets.reel2, CFG.assets.reel2Poster); blurUp(); if (REDUCED) return; $$(".phone").forEach((p) => { const d = parseFloat(p.dataset.float) || 1; gsap.to(p, { y: 18 * d, duration: 3.4, ease: "sine.inOut", yoyo: true, repeat: -1 }); gsap.to(p, { rotateZ: 1.4 * d, duration: 5, ease: "sine.inOut", yoyo: true, repeat: -1, scrollTrigger: { trigger: "#reels", start: "top bottom", end: "bottom top", scrub: 1 } }); }); } /* ---------- PACKAGES (real pricing + sqft tier selector) ---------- */ function packages() { const track = $("#pkgTrack"), chipsHost = $("#pkgChips"), cf = $("#pkgCf"); const list = CFG.packages || []; $("#pkgTotal").textContent = String(list.length).padStart(2, "0"); const check = ``; track.innerHTML = list.map((p, pi) => { const feats = (p.includes || []).map((f) => `
  • ${check}${f}
  • `).join(""); const cta = (CFG.urls && CFG.urls.booking && CFG.urls.booking !== "#") ? `Book ${p.tier}` : `Book ${p.tier}`; return `
    ${p.badge ? `${p.badge}` : ""}
    ${p.tier}
    ${cta}
    `; }).join(""); chipsHost.innerHTML = list.map((p, pi) => ``).join(""); const cards = () => [...track.children]; const feat = Math.max(0, list.findIndex((p) => p.featured)); const totalEl = $("#pkgTotal"), nowEl = $("#pkgNow"); if (totalEl) totalEl.textContent = String(list.length).padStart(2, "0"); const highlight = (i) => { $$("#pkgChips button").forEach((c, j) => c.classList.toggle("active", j === i)); cards().forEach((c, j) => c.classList.toggle("is-center", j === i)); if (nowEl) nowEl.textContent = String(i + 1).padStart(2, "0"); }; // native horizontal scroll β€” nothing intercepts pointer events, so every card stays clickable let lock = 0; const goTo = (i) => { const cs = cards(); i = Math.max(0, Math.min(cs.length - 1, i)); const card = cs[i]; if (!card) return; highlight(i); lock = Date.now() + 900; // keep the chosen card highlighted while it glides in const max = cf.scrollWidth - cf.clientWidth; const target = Math.max(0, Math.min(max, card.offsetLeft - (cf.clientWidth - card.offsetWidth) / 2)); cf.scrollTo({ left: target, behavior: REDUCED ? "auto" : "smooth" }); }; const nearest = () => { const mid = cf.scrollLeft + cf.clientWidth / 2; let best = 0, d = Infinity; cards().forEach((c, i) => { const cm = c.offsetLeft + c.offsetWidth / 2; if (Math.abs(cm - mid) < d) { d = Math.abs(cm - mid); best = i; } }); return best; }; chipsHost.addEventListener("click", (e) => { const b = e.target.closest("button[data-chip]"); if (b) goTo(+b.dataset.chip); }); const nextB = $("#pkgNext"), prevB = $("#pkgPrev"); if (nextB) nextB.addEventListener("click", () => goTo(nearest() + 1)); if (prevB) prevB.addEventListener("click", () => goTo(nearest() - 1)); let raf = 0; cf.addEventListener("scroll", () => { if (!raf) raf = requestAnimationFrame(() => { if (Date.now() > lock) highlight(nearest()); raf = 0; }); }, { passive: true }); requestAnimationFrame(() => goTo(feat)); } /* ---------- TESTIMONIALS: draggable, auto-scrolling carousel ---------- */ function testimonials() { const scroll = $("#testiScroll"), track = $("#testiTrack"); if (!scroll || !track) return; const list = CFG.reviews || []; if (!list.length) return; track.innerHTML = list.map((r) => `
    β€œ

    ${r.q}

    β˜…β˜…β˜…β˜…β˜…
    ${r.n}${r.r}
    `).join(""); // drag to scroll let down = false, sx = 0, sl = 0, moved = false; scroll.addEventListener("pointerdown", (e) => { down = true; moved = false; sx = e.clientX; sl = scroll.scrollLeft; scroll.classList.add("drag"); scroll.setPointerCapture(e.pointerId); }); scroll.addEventListener("pointermove", (e) => { if (!down) return; const dx = e.clientX - sx; if (Math.abs(dx) > 10) moved = true; scroll.scrollLeft = sl - dx; }); const up = (e) => { if (!down) return; down = false; scroll.classList.remove("drag"); try { scroll.releasePointerCapture(e.pointerId); } catch (_) {} }; scroll.addEventListener("pointerup", up); scroll.addEventListener("pointercancel", up); scroll.addEventListener("click", (e) => { if (moved) { e.preventDefault(); e.stopPropagation(); } }, true); // gentle auto-advance; pauses on hover / interaction if (!REDUCED) { let paused = false, last = performance.now(); const speed = 26; // px/sec scroll.addEventListener("pointerenter", () => (paused = true)); scroll.addEventListener("pointerleave", () => (paused = false)); scroll.addEventListener("pointerdown", () => (paused = true)); function tick(now) { const dt = (now - last) / 1000; last = now; if (!paused && !down) { const max = scroll.scrollWidth - scroll.clientWidth; if (max > 4) { scroll.scrollLeft += speed * dt; if (scroll.scrollLeft >= max - 1) scroll.scrollLeft = 0; } } requestAnimationFrame(tick); } requestAnimationFrame(tick); } if (!REDUCED) gsap.from($$(".quote", track), { opacity: 0, y: 24, duration: 0.8, ease: "power3.out", stagger: 0.08, scrollTrigger: { trigger: scroll, start: "top 88%" } }); } /* ---------- tilt / magnetic ---------- */ function tilt() { if (REDUCED || TOUCH) return; $$("[data-tilt]").forEach((card) => { card.addEventListener("pointermove", (e) => { const r = card.getBoundingClientRect(); gsap.to(card, { rotateX: ((e.clientY - r.top) / r.height - 0.5) * -5, rotateY: ((e.clientX - r.left) / r.width - 0.5) * 5, transformPerspective: 900, duration: 0.5, ease: "power3.out" }); }); card.addEventListener("pointerleave", () => gsap.to(card, { rotateX: 0, rotateY: 0, duration: 0.7, ease: "power3.out" })); }); } function magnetic() { if (REDUCED || TOUCH) return; $$("[data-magnetic]").forEach((el) => { el.addEventListener("pointermove", (e) => { const r = el.getBoundingClientRect(); gsap.to(el, { x: (e.clientX - r.left - r.width / 2) * 0.3, y: (e.clientY - r.top - r.height / 2) * 0.4, duration: 0.5, ease: "power3.out" }); }); el.addEventListener("pointerleave", () => gsap.to(el, { x: 0, y: 0, duration: 0.6, ease: "elastic.out(1,0.4)" })); }); } /* ---------- neon background fx attached to scroll ---------- */ function fx() { const fillBar = $("#scrollFill"); // top neon progress bar ScrollTrigger.create({ start: 0, end: "max", onUpdate: (self) => { if (fillBar) fillBar.style.width = (self.progress * 100).toFixed(2) + "%"; }, }); if (REDUCED) return; $$(".fx .orb").forEach((o) => gsap.to(o, { yPercent: (parseFloat(o.dataset.orb) || 0.05) * 600, ease: "none", scrollTrigger: { trigger: "body", start: "top top", end: "bottom bottom", scrub: 1.2 } })); $$(".fx .beam").forEach((b) => gsap.to(b, { xPercent: (parseFloat(b.dataset.fx) || 0.5) * 18, yPercent: (parseFloat(b.dataset.fx) || 0.5) * -40, ease: "none", scrollTrigger: { trigger: "body", start: "top top", end: "bottom bottom", scrub: 1.5 } })); } /* ---------- nav / urls / form ---------- */ function chrome() { const nav = $("#nav"), toggle = $("#navToggle"); ScrollTrigger.create({ start: "top -40", end: 99999, onUpdate: (self) => nav.classList.toggle("scrolled", self.scroll() > 40), onToggle: (self) => nav.classList.toggle("scrolled", self.isActive) }); nav.classList.toggle("scrolled", window.scrollY > 40); toggle.addEventListener("click", () => { const open = nav.classList.toggle("open"); toggle.setAttribute("aria-expanded", String(open)); }); document.addEventListener("click", (e) => { const a = e.target.closest('a[href^="#"]'); if (!a) return; const id = a.getAttribute("href"); if (id.length < 2) return; const tgt = document.querySelector(id); if (!tgt) return; e.preventDefault(); nav.classList.remove("open"); toggle.setAttribute("aria-expanded", "false"); scrollToEl(tgt); }); const booking = CFG.urls && CFG.urls.booking, portal = CFG.urls && CFG.urls.portal; if (booking && booking !== "#") { $$('.nav-cta, .mob-book, .hero-cta-row .btn:not(.ghost)').forEach((b) => { b.setAttribute("href", booking); b.setAttribute("target", "_blank"); b.setAttribute("rel", "noopener"); }); } const portalBtn = $("#portalBtn"), portalPh = $("#portalPh"), footPortal = $("#footPortal"); if (portal && portal !== "#") { portalBtn.setAttribute("href", portal); footPortal.setAttribute("href", portal); if (portalPh) portalPh.style.display = "none"; } else { [portalBtn, footPortal].forEach((b) => b && b.addEventListener("click", (e) => e.preventDefault())); } const form = $("#bookingForm"), msg = $("#formMsg"); form.addEventListener("submit", (e) => { e.preventDefault(); const name = $("#f-name").value.trim(), email = $("#f-email").value.trim(), addr = $("#f-addr").value.trim(); if (!name || !email || !addr) { msg.textContent = "Please add your name, email, and the property address."; return; } if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { msg.textContent = "That email doesn't look right β€” mind checking it?"; return; } if (booking && booking !== "#") { msg.textContent = "Opening the booking system…"; window.open(booking, "_blank", "noopener"); } else { msg.textContent = "Thanks, " + name + " β€” request captured. Connect a booking endpoint to send it automatically."; form.reset(); } }); $("#yr").textContent = new Date().getFullYear(); } /* ---------- WOW layer: hero char reveal Β· lateral scroll drift Β· neon cursor ---------- */ function splitHero() { const h = $(".hero h1"); if (!h || REDUCED) return; (function walk(node) { [...node.childNodes].forEach((n) => { if (n.nodeType === 3) { const frag = document.createDocumentFragment(); n.textContent.split("").forEach((c) => { const s = document.createElement("span"); s.className = "ch"; s.textContent = c === " " ? "\u00A0" : c; frag.appendChild(s); }); node.replaceChild(frag, n); } else if (n.nodeType === 1 && n.tagName !== "BR") walk(n); }); })(h); gsap.from(h.querySelectorAll(".ch"), { yPercent: 112, opacity: 0, filter: "blur(8px)", duration: 1.05, ease: "power3.out", stagger: 0.022, delay: 0.12, clearProps: "filter" }); } function lateral() { if (REDUCED) return; // statement lines drift sideways (alternating) as you scroll down $$(".statement .line i").forEach((el, i) => { gsap.fromTo(el, { xPercent: i % 2 ? 7 : -7 }, { xPercent: i % 2 ? -3 : 3, ease: "none", scrollTrigger: { trigger: el, start: "top 96%", end: "bottom 10%", scrub: 0.8 } }); }); if (!window.matchMedia("(min-width:1025px)").matches) return; // services cards slide in from alternate sides $$(".svc").forEach((el, i) => { gsap.fromTo(el, { x: i % 2 ? 70 : -70, opacity: 0.35 }, { x: 0, opacity: 1, ease: "power2.out", scrollTrigger: { trigger: el, start: "top 92%", end: "top 46%", scrub: 0.7 } }); }); // reel phones converge toward center on scroll const phones = $$(".reels .phone"); phones.forEach((el, i) => { gsap.fromTo(el, { x: i % 2 ? 90 : -90, rotate: i % 2 ? 3 : -3 }, { x: 0, rotate: 0, ease: "power1.out", scrollTrigger: { trigger: el, start: "top 96%", end: "top 40%", scrub: 0.8 } }); }); } function about() { const cfg = CFG.about; if (!cfg) return; const txt = $("#aboutText"); if (txt) txt.textContent = cfg.text || ""; const st = $("#aboutStats"); if (st) st.innerHTML = (cfg.stats || []).map((s) => `
    ${s.v}${s.l}
    `).join(""); } function init() { brokerages(); hero(); gallery(); reels(); packages(); testimonials(); reveals(); about(); process(); tilt(); magnetic(); fx(); splitHero(); lateral(); chrome(); ScrollTrigger.refresh(); window.addEventListener("load", () => ScrollTrigger.refresh()); } document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", init) : init(); })();