Files
2026-07-18 19:37:40 +05:30

1491 lines
66 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "@fontsource-variable/inter";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "@fontsource/jetbrains-mono/600.css";
import "./styles.css";
console.log("%cCreated by Sarthak", "font-size:14px;font-weight:700;color:#F2A83B;");
/* ---------- CTA sample-address estimate (standalone so it always runs) ---------- */
(function(){
function init(){
var form = document.getElementById("ctaForm");
var sel = document.getElementById("ctaAddr");
var btn = document.getElementById("ctaEstimateBtn");
var panel = document.getElementById("ctaEstimate");
if(!sel || !panel) return;
var money = function(n){ return "$" + Number(n).toLocaleString("en-US"); };
var set = function(id, val){ var el = document.getElementById(id); if(el) el.textContent = val; };
function render(scroll){
var opt = sel.options[sel.selectedIndex];
if(!opt || opt.dataset.placeholder){ panel.hidden = true; return; }
set("estAddr", opt.value);
set("estArea", Number(opt.dataset.area).toLocaleString("en-US") + " sq ft");
set("estSquares", opt.dataset.squares + " sq");
set("estPitch", opt.dataset.pitch);
set("estMaterial", opt.dataset.material);
set("estPrice", money(opt.dataset.low) + " " + money(opt.dataset.high));
panel.hidden = false;
if(scroll) panel.scrollIntoView({behavior:"smooth", block:"nearest"});
}
function go(e){ if(e) e.preventDefault(); render(true); }
if(form) form.addEventListener("submit", go);
if(btn) btn.addEventListener("click", go);
sel.addEventListener("change", function(){ if(!panel.hidden) render(false); });
}
if(document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();
(function(){
var reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
var finePointer = matchMedia("(pointer: fine)").matches;
var fmt = function(n){ return n.toLocaleString("en-US"); };
/* ---------- scrollbar materialises only while scrolling ---------- */
(function(){
var to = null;
addEventListener("scroll", function(){
document.documentElement.classList.add("scrolling");
clearTimeout(to);
to = setTimeout(function(){ document.documentElement.classList.remove("scrolling"); }, 900);
}, { passive: true, capture: true });
})();
/* ---------- mobile scroll progress bar ---------- */
(function(){
var bar = document.getElementById("mprogBar");
if(!bar) return;
var raf = null;
function upd(){
raf = null;
var doc = document.scrollingElement || document.documentElement;
var max = Math.max(1, doc.scrollHeight - innerHeight);
bar.style.transform = "scaleX(" + Math.min(1, window.scrollY / max).toFixed(4) + ")";
}
addEventListener("scroll", function(){ if(raf == null) raf = requestAnimationFrame(upd); }, { passive: true });
upd();
})();
/* ---------- footer: hail alerts by ZIP ---------- */
(function(){
var form = document.getElementById("zipForm");
if(!form) return;
var zin = document.getElementById("zipInput");
form.addEventListener("submit", function(e){
e.preventDefault();
var v = zin.value.trim();
if(!/^\d{5}$/.test(v)){ zin.focus(); zin.select(); return; }
form.innerHTML = "<div class='fa-done'><span class='tag'>&#9873; WATCHING " + v + "</span>" +
"<span>First alert lands after the next radar-verified swath. No spam, only storms.</span></div>";
});
})();
/* ---------- scroll reveals: 150ms, 60ms stagger ---------- */
var revealEls = [].slice.call(document.querySelectorAll(".reveal"));
if(!reduced && "IntersectionObserver" in window){
var groups = new Map();
var io = new IntersectionObserver(function(entries){
entries.forEach(function(e){
if(!e.isIntersecting && e.boundingClientRect.top >= 0) return;
io.unobserve(e.target);
var parent = e.target.parentElement;
var idx = groups.get(parent) || 0;
groups.set(parent, idx + 1);
setTimeout(function(){ e.target.classList.add("in"); }, idx * 60);
setTimeout(function(){ groups.set(parent, Math.max(0,(groups.get(parent)||1)-1)); }, 400);
});
}, {threshold:.15});
revealEls.forEach(function(el){ io.observe(el); });
} else {
revealEls.forEach(function(el){ el.classList.add("in"); });
}
/* ---------- count-ups: one-shot, comma-formatted ---------- */
var counters = [].slice.call(document.querySelectorAll("[data-count]"));
function runCounter(el){
var target = +el.dataset.count;
var suffix = el.dataset.suffix || "", prefix = el.dataset.prefix || "";
var dec = +(el.dataset.decimals || 0);
function print(v){
el.textContent = prefix + (dec ? v.toFixed(dec) : fmt(Math.round(v))) + suffix;
}
if(reduced){ print(target); return; }
var t0 = null, dur = 2400;
function step(ts){
if(!t0) t0 = ts;
var p = Math.min((ts - t0)/dur, 1);
p = 1 - Math.pow(1-p, 3);
print(target * p);
if(p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
if("IntersectionObserver" in window){
var cio = new IntersectionObserver(function(entries){
entries.forEach(function(e){
if(e.isIntersecting){ cio.unobserve(e.target); runCounter(e.target); }
});
}, {threshold:.6});
counters.forEach(function(el){ cio.observe(el); });
} else counters.forEach(runCounter);
/* ---------- hero contour-line shader (survey isolines, cyan) ---------- */
(function(){
var cv = document.getElementById("heroFx");
if(!cv) return;
var gl = cv.getContext("webgl", {alpha:true, antialias:false});
if(!gl){ cv.remove(); return; }
var vsrc = "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
var fsrc =
"precision mediump float;" +
"uniform vec2 u_res;uniform float u_t;uniform vec2 u_m;" +
"void main(){" +
" vec2 uv=gl_FragCoord.xy/u_res;" +
" vec2 p=uv*vec2(u_res.x/u_res.y,1.);" +
" p+=(u_m-.5)*.10;" +
" float v=sin(p.x*4.2+u_t*.25)+sin(p.y*5.1-u_t*.2)+sin((p.x+p.y)*3.1+u_t*.12)+sin(length(p-vec2(1.2,.2))*6.-u_t*.3);" +
" float f=fract(v*1.5);" +
" float line=smoothstep(.90,.975,f)*(1.-smoothstep(.975,1.,f));" +
" float mask=smoothstep(1.3,.1,distance(uv,vec2(.78,.82)));" +
" gl_FragColor=vec4(.27,.69,.79,line*mask*.085);" +
"}";
function sh(t,s){var o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);return o;}
var pr=gl.createProgram();
gl.attachShader(pr,sh(gl.VERTEX_SHADER,vsrc));
gl.attachShader(pr,sh(gl.FRAGMENT_SHADER,fsrc));
gl.linkProgram(pr);gl.useProgram(pr);
var buf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buf);
gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),gl.STATIC_DRAW);
var loc=gl.getAttribLocation(pr,"p");
gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);
var uRes=gl.getUniformLocation(pr,"u_res"),uT=gl.getUniformLocation(pr,"u_t"),uM=gl.getUniformLocation(pr,"u_m");
var hero=document.querySelector(".hero"),mx=.5,my=.5;
if(finePointer) hero.addEventListener("pointermove",function(e){
var r=hero.getBoundingClientRect();
mx=(e.clientX-r.left)/r.width; my=(e.clientY-r.top)/r.height;
});
var dpr=Math.min(devicePixelRatio||1,1.5);
function rs(){var r=hero.getBoundingClientRect();cv.width=r.width*dpr;cv.height=r.height*dpr;gl.viewport(0,0,cv.width,cv.height);}
rs();addEventListener("resize",rs);
gl.enable(gl.BLEND);gl.blendFunc(gl.SRC_ALPHA,gl.ONE_MINUS_SRC_ALPHA);
function paint(t){
gl.uniform2f(uRes,cv.width,cv.height);
gl.uniform1f(uT,t);
gl.uniform2f(uM,mx,1-my);
gl.drawArrays(gl.TRIANGLES,0,3);
}
paint(0); /* warm the compile at load */
if(reduced){ paint(8); return; }
var vis=true,t0=performance.now();
if("IntersectionObserver" in window) new IntersectionObserver(function(en){vis=en[0].isIntersecting;}).observe(hero);
(function frame(now){
if(vis) paint((now-t0)/1000);
requestAnimationFrame(frame);
})(t0);
})();
/* ---------- cursor-following hero glow ---------- */
(function(){
var g=document.getElementById("heroGlow");
if(!g) return;
if(!finePointer || reduced){ g.remove(); return; }
var hero=document.querySelector(".hero");
hero.addEventListener("pointermove",function(e){
var r=hero.getBoundingClientRect();
g.style.opacity="1";
g.style.transform="translate("+(e.clientX-r.left-260)+"px,"+(e.clientY-r.top-260)+"px)";
});
hero.addEventListener("pointerleave",function(){ g.style.opacity="0"; });
})();
/* ---------- card tilt physics (spring, with glare tracking) ---------- */
if(finePointer && !reduced){
[].slice.call(document.querySelectorAll(".ind,.risk-card,.plan,.founder-card,.feat,.oc")).forEach(function(el){
var rx=0,ry=0,tx=0,ty=0,vx=0,vy=0,raf=null,over=false;
function frame(){
vx+=(tx-rx)*.16; vy+=(ty-ry)*.16; vx*=.72; vy*=.72;
rx+=vx; ry+=vy;
if(!over && Math.abs(rx)<.02 && Math.abs(ry)<.02 && Math.abs(vx)<.02 && Math.abs(vy)<.02){
el.style.transform=""; raf=null; return;
}
el.style.transform="perspective(900px) rotateX("+rx.toFixed(2)+"deg) rotateY("+ry.toFixed(2)+"deg) translateY(-2px)";
raf=requestAnimationFrame(frame);
}
el.addEventListener("pointerenter",function(){
over=true; el.classList.add("tilting");
if(!raf) raf=requestAnimationFrame(frame);
});
el.addEventListener("pointermove",function(e){
var r=el.getBoundingClientRect();
var px=(e.clientX-r.left)/r.width, py=(e.clientY-r.top)/r.height;
ty=(px-.5)*5; tx=(.5-py)*5;
el.style.setProperty("--gx",(px*100)+"%");
el.style.setProperty("--gy",(py*100)+"%");
});
el.addEventListener("pointerleave",function(){ over=false; tx=0; ty=0; });
});
}
/* ---------- mobile nav ---------- */
(function(){
var mb = document.getElementById("menuBtn");
if(!mb) return;
mb.addEventListener("click", function(){
var open = document.body.classList.toggle("nav-open");
mb.setAttribute("aria-expanded", open ? "true" : "false");
});
[].slice.call(document.querySelectorAll(".nav-links a")).forEach(function(a){
a.addEventListener("click", function(){
document.body.classList.remove("nav-open");
mb.setAttribute("aria-expanded", "false");
});
});
})();
/* The 3D scene (three.js + gsap, ~540KB) loads after first paint;
everything that needs the model lives inside initEstimator. */
function initEstimator(createScene){
/* ---------- 3D blueprint model: source of truth for the estimator ---------- */
var modelWrap = document.getElementById("modelWrap");
var roof3d = createScene(modelWrap, { reduced: reduced });
roof3d.stage(1); /* no scan phase on this page: model arrives fully inked */
roof3d.onDragStart(function(){
var hint = document.getElementById("dragHint");
if(hint) hint.style.opacity = "0";
});
(function(){
var slot = document.getElementById("estSlot");
function size(){
var r = slot.getBoundingClientRect();
if(r.width > 2 && r.height > 2) roof3d.resize(r.width, r.height);
}
if("ResizeObserver" in window) new ResizeObserver(size).observe(slot);
else addEventListener("resize", size);
size();
})();
/* ---------- estimator: quantities derived from the 3D model ----------
rates below are pulled from a real Certainteed Landmark AR tear-off/install
invoice (roofing material + labor line items on a 12/12, 64.89 sq job):
shingles $203.19/sq, synthetic underlayment ~$44.53/sq (10 sq/roll @ $445.30),
ridge/hip cap ~$4.00/lf, accessories (drip edge, vents, flashing, nails,
starter, sealant) ~$53/sq, plus a steep-pitch labor surcharge that kicks in
at 8/12 and climbs with pitch &mdash; the old flat/guessed numbers didn't scale
with job size or pitch at all. */
function makeEstimate(o){
var CANON_W = {gable:42, hip:46, storm:58, estate:42, mustang:48};
var CANON_P = {gable:6, hip:6, storm:5, estate:6, mustang:12};
o.wf = CANON_W[o.style] || o.wf;
o.pn = CANON_P[o.style] || o.pn;
var mm = roof3d.measure({style:o.style, wf:o.wf, pn:o.pn});
var layers = o.layers || 1;
var rate = o.shingleRate || 203;
/* hips and complex multi-facet roofs honestly carry more cut waste than plain gables */
var waste = (o.style === "hip" || o.style === "mustang") ? 1.12 : 1.10;
var squares = Math.ceil(mm.roofSqFt * waste / 100);
var shingles = squares * rate;
var rolls = Math.ceil(squares / 10), under = rolls * 445;
var ridge = Math.round(mm.ridgeLf * 4.0);
var miscRate = o.miscRate || 53;
var misc = Math.round(squares * miscRate);
var tear = squares * 78 * layers;
var labor = Math.round(o.crew * o.days * 760);
var gutters = o.gutters ? Math.round(o.gutters * 12) : 0;
var extras = o.extras || [];
var extrasSum = extras.reduce(function(a,x){ return a + x[1]; }, 0);
/* real steep-fee tiering: 8-9/12, 10-11/12, 12/12+ */
var steepRate = o.pn >= 12 ? 26 : o.pn >= 10 ? 20 : o.pn >= 8 ? 14.29 : 0;
var steep = steepRate ? Math.round(squares * steepRate) : 0;
var total = shingles + under + ridge + misc + tear + labor + gutters + extrasSum + steep;
var lines = [
["SCAN &nbsp;&nbsp;" + o.scanLabel, "<span class='ok'>OK " + o.scanTime + "</span>", null],
["MEASURE &nbsp;roof " + fmt(mm.roofSqFt) + " sq ft &middot; " + o.pn + "/12 &middot; " + mm.facets + " facets", "<span class='ok'>OK</span>", "roof"],
["<b>" + (o.shingleLabel || "Architectural shingles") + "</b> &middot; " + squares + " sq", "$" + fmt(shingles), "roof"],
["<b>Synthetic underlayment</b> &middot; " + rolls + " rolls", "$" + fmt(under), "roof"],
["<b>Ridge" + (o.style === "hip" || o.style === "mustang" ? " + hip" : "") + " cap</b> &middot; " + mm.ridgeLf + " lf", "$" + fmt(ridge), "ridge"],
["<b>Drip edge, vents, fasteners</b>", "$" + fmt(misc), "eave"],
["<b>Tear-off + disposal</b> &middot; " + layers + " layer" + (layers > 1 ? "s" : ""), "$" + fmt(tear), "roof"]
];
if(steep) lines.push(["<b>Steep-pitch labor</b> &middot; " + o.pn + "/12 &middot; " + squares + " sq", "$" + fmt(steep), "roof"]);
if(o.gutters) lines.push(["<b>Gutters</b> &middot; " + o.gutters + " lf aluminum", "$" + fmt(gutters), "eave"]);
extras.forEach(function(x){ lines.push(["<b>" + x[0] + "</b>", "$" + fmt(x[1]), null]); });
lines.push(["<b>Labor</b> &middot; crew of " + o.crew + " &middot; " + o.days + " day" + (o.days > 1 ? "s" : ""), "$" + fmt(labor), null]);
if(o.damage){
var sev = o.damage > 38 ? "HIGH" : o.damage > 26 ? "MODERATE" : "LOW";
lines.splice(2, 0, ["DAMAGE &nbsp;" + o.damage + " impacts &middot; facet F2 &middot; severity " + sev, "<span class='flag'>FLAG</span>", "damage"]);
lines.splice(3, 0, ["<b>Digital Twin evidence pack</b> &middot; " + (o.damage * 4 + 38) + " photos", "included", "damage"]);
}
return {
roof:{ style:o.style, wf:o.wf, pn:o.pn },
lines:lines, total:"$" + fmt(total), time:o.time
};
}
var jobs = [
makeEstimate({style:"gable", wf:42, pn:6, scanLabel:"aerial imagery + street view", scanTime:"0.4s",
layers:2, crew:5, days:2, gutters:110,
extras:[["Chimney reflash + cricket",680],["Skylight curb + reflash &middot; 2 units",940]],
time:"3.1 seconds."}),
makeEstimate({style:"hip", wf:46, pn:8, scanLabel:"aerial imagery + street view", scanTime:"0.5s",
shingleRate:203, crew:5, days:1, time:"3.4 seconds."}),
makeEstimate({style:"storm", wf:58, pn:5, scanLabel:"hail impact detection", scanTime:"0.6s",
shingleRate:255, shingleLabel:"Class-4 impact shingles",
crew:6, days:2, damage:41, gutters:148,
extras:[["Decking replacement &middot; 12 sheets",1560],["Drip edge + valley shield &middot; code items",1100],["Permits + inspection",850]],
time:"2.8 seconds. Claim-ready."})
];
var body = document.getElementById("estBody");
var timer = [];
function clearTimers(){ timer.forEach(clearTimeout); timer = []; }
function renderJob(i, animate){ renderEstimate(jobs[i], animate); }
function renderEstimate(j, animate){
clearTimers();
roof3d.set(j.roof);
document.getElementById("estTime").textContent = j.time;
body.innerHTML = "";
var frag = document.createDocumentFragment();
j.lines.forEach(function(l){
var d = document.createElement("div");
d.className = "est-line";
d.innerHTML = "<span class='k'>"+l[0]+"</span><span class='v'>"+l[1]+"</span>";
if(l[2]){
d.setAttribute("data-hl", l[2]);
d.addEventListener("mouseenter", function(){ roof3d.highlight(l[2]); });
d.addEventListener("mouseleave", function(){ roof3d.highlight(null); });
}
frag.appendChild(d);
});
var tot = document.createElement("div");
tot.className = "est-total";
tot.innerHTML = "<span class='k'>ESTIMATE TOTAL</span><span class='v'>"+j.total+"</span>";
frag.appendChild(tot);
body.appendChild(frag);
var rows = [].slice.call(body.querySelectorAll(".est-line"));
if(!animate || reduced){
rows.forEach(function(r){ r.classList.add("on"); });
tot.classList.add("on");
return;
}
var roofBox = document.querySelector(".est-roof");
roofBox.classList.remove("scanning");
void roofBox.offsetWidth;
roofBox.classList.add("scanning");
timer.push(setTimeout(function(){ roofBox.classList.remove("scanning"); }, 1300));
rows.forEach(function(r, idx){
timer.push(setTimeout(function(){ r.classList.add("on"); }, 220 + idx * 260));
});
timer.push(setTimeout(function(){ tot.classList.add("on"); }, 220 + rows.length * 260 + 180));
}
/* typed address: deterministic scripted estimate */
function hashStr(s){
var h = 2166136261;
for(var i = 0; i < s.length; i++){ h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function rng(seed){
return function(){
seed = Math.imul(seed ^ (seed >>> 15), 2246822519);
seed = Math.imul(seed ^ (seed >>> 13), 3266489917);
return ((seed ^= seed >>> 16) >>> 0) / 4294967296;
};
}
function jobFromAddress(addr){
var clean = addr.replace(/[<>&"]/g, "").replace(/\s+/g, " ").trim();
var r = rng(hashStr(clean.toLowerCase()));
var pitch = 4 + Math.floor(r() * 7);
var hipStyle = r() < .45;
var damaged = r() < .3;
var impacts = damaged ? 18 + Math.floor(r() * 36) : 0;
var widthFt = 30 + Math.round(r() * 30);
var layers = r() < .3 ? 2 : 1;
var crew = 4 + Math.floor(r() * 3);
var days = 1 + Math.round(r() * 3) / 2;
var short = clean.length > 30 ? clean.slice(0, 28) + "..." : clean;
return makeEstimate({
style: hipStyle ? "hip" : "gable", wf: widthFt, pn: pitch,
scanLabel: short, scanTime: "0.5s",
layers: layers, crew: crew, days: days,
damage: impacts, misc: 600 + Math.round(r() * 350),
time: (2.4 + r() * 1.2).toFixed(1) + " seconds." + (damaged ? " Claim-ready." : "")
});
}
/* the addresses we have a client-supplied aerial survey for get their own modelled house */
var BENT_OAK = "5705 Bent Oak Place, Dallas, TX 75248";
function estateJob(){
return makeEstimate({
style: "estate", wf: 42, pn: 6,
scanLabel: BENT_OAK, scanTime: "0.6s",
layers: 1, crew: 6, days: 3, gutters: 165,
extras: [["Chimney reflash + cricket", 680], ["Pool deck coping reseal", 420]],
time: "3.3 seconds."
});
}
var MUSTANG = "12552 Mustang Circle, Forney, TX 75126";
function mustangJob(){
return makeEstimate({
style: "mustang", wf: 48, pn: 12,
scanLabel: MUSTANG, scanTime: "0.7s",
layers: 1, crew: 7, days: 3, gutters: 240, miscRate: 58,
extras: [["Ice &amp; water shield &middot; valleys + eaves", 1850], ["Steep-pitch safety rigging", 620]],
time: "3.6 seconds."
});
}
var chips = [].slice.call(document.querySelectorAll(".est-chips .chip"));
/* the two addresses with a client-supplied aerial survey get their modelled house;
anything else falls back to the deterministic hashed estimate */
function jobForAddress(addr){
if(addr === MUSTANG) return mustangJob();
if(addr === BENT_OAK) return estateJob();
return jobFromAddress(addr);
}
/* custom address dropdown: native <select> popups can't be themed to match the site.
picking an address loads its estimate immediately, no separate Run step. */
var addrSelect = document.getElementById("addrSelect");
var addrBtn = document.getElementById("addrBtn");
var addrBtnLabel = document.getElementById("addrBtnLabel");
var addrList = document.getElementById("addrList");
var addrOptions = [].slice.call(addrList.querySelectorAll("li"));
var addrValue = addrOptions.length ? addrOptions[0].dataset.value : "";
function closeAddrList(){
addrSelect.classList.remove("open");
addrBtn.setAttribute("aria-expanded", "false");
addrList.hidden = true;
}
function openAddrList(){
addrSelect.classList.add("open");
addrBtn.setAttribute("aria-expanded", "true");
addrList.hidden = false;
}
addrBtn.addEventListener("click", function(){
if(addrSelect.classList.contains("open")) closeAddrList(); else openAddrList();
});
addrOptions.forEach(function(li){
li.addEventListener("click", function(){
if(!li.classList.contains("on")){
addrOptions.forEach(function(o){ o.classList.remove("on"); o.setAttribute("aria-selected", "false"); });
li.classList.add("on");
li.setAttribute("aria-selected", "true");
addrValue = li.dataset.value;
addrBtnLabel.textContent = addrValue;
chips.forEach(function(x){ x.setAttribute("aria-pressed", "false"); });
renderEstimate(jobForAddress(addrValue), true);
}
closeAddrList();
});
});
document.addEventListener("click", function(e){
if(!addrSelect.contains(e.target)) closeAddrList();
});
addEventListener("keydown", function(e){
if(e.key === "Escape" && addrSelect.classList.contains("open")){ closeAddrList(); addrBtn.focus(); }
});
chips.forEach(function(c){
c.addEventListener("click", function(){
chips.forEach(function(x){ x.setAttribute("aria-pressed", x === c ? "true" : "false"); });
renderJob(+c.dataset.job, true);
});
});
/* closer CTA: the crane card runs the live estimator up top */
var ctaForm = document.getElementById("ctaForm");
if(ctaForm){
ctaForm.addEventListener("submit", function(e){
e.preventDefault();
var v = document.getElementById("ctaAddr").value.trim();
if(v.length < 5){ document.getElementById("ctaAddr").focus(); return; }
addrValue = v;
addrBtnLabel.textContent = v;
addrOptions.forEach(function(o){ o.classList.remove("on"); o.setAttribute("aria-selected", "false"); });
chips.forEach(function(x){ x.setAttribute("aria-pressed", "false"); });
renderEstimate(jobForAddress(v), true);
var est = document.querySelector(".est");
scrollTo({ top: est.getBoundingClientRect().top + window.scrollY - 76, behavior: reduced ? "auto" : "smooth" });
});
}
/* auto-run the first estimate when the hero is visible;
the house draws itself in with the same plotter wipe chips use */
if(!reduced && "IntersectionObserver" in window){
var est = document.querySelector(".est");
var eio = new IntersectionObserver(function(entries){
if(entries[0].isIntersecting){
eio.disconnect();
roof3d.wipe();
renderJob(0, true);
}
}, {threshold:.3});
eio.observe(est);
} else renderJob(0, false);
}
(function(){
var load = function(){
import("./scene/scene.js").then(function(m){ initEstimator(m.createScene); });
};
if("requestIdleCallback" in window) requestIdleCallback(load, { timeout: 1500 });
else setTimeout(load, 250);
})();
/* ---------- seamless ticker loop ---------- */
var track = document.getElementById("tickerTrack");
if(track) track.innerHTML += track.innerHTML;
/* ---------- chaos section: phase hover-linking ---------- */
var chaos = document.getElementById("chaos");
if(chaos){
var hotPhase = null;
function setPhase(p){
if(p === hotPhase) return;
hotPhase = p;
[].forEach.call(chaos.querySelectorAll(".hot"), function(n){ n.classList.remove("hot"); });
if(p) [].forEach.call(chaos.querySelectorAll('[data-phase="' + p + '"]'), function(n){ n.classList.add("hot"); });
}
chaos.addEventListener("mouseover", function(ev){
var el = ev.target.closest("[data-phase]");
setPhase(el ? el.getAttribute("data-phase") : null);
});
chaos.addEventListener("mouseleave", function(){ setPhase(null); });
}
/* ---------- station rail: section index ---------- */
(function(){
var rail = document.getElementById("rail"), track = document.getElementById("railTrack");
if(!rail || !track) return;
var car = document.getElementById("railCar"), read = document.getElementById("railRead");
var defs = [
["hero","01 · ESTIMATE"], ["compare","02 · PROOF"], ["intel","03 · INTEL"],
["chaos","04 · COST"], ["product","05 · PRODUCT"], ["outcomes","06 · OUTCOMES"],
["industries","07 · INDUSTRIES"], ["risk","08 · RISK"], ["casefile","09 · CASE FILE"],
["founder","10 · FOUNDER"], ["pricing","11 · PRICING"], ["terms","12 · GLOSSARY"],
["book","13 · BOOK"]
];
var secs = defs.map(function(d){ return {el:document.getElementById(d[0]), label:d[1]}; })
.filter(function(s){ return s.el; });
var nodes = secs.map(function(s){
var b = document.createElement("button");
b.className = "rail-node"; b.type = "button"; b.title = s.label;
b.setAttribute("aria-label", s.label);
b.addEventListener("click", function(){
scrollTo({top:s.el.offsetTop - 52, behavior:reduced ? "auto" : "smooth"});
});
track.appendChild(b);
return b;
});
var tops = [], maxScroll = 1;
function layout(){
var doc = document.scrollingElement || document.documentElement;
maxScroll = Math.max(1, doc.scrollHeight - innerHeight);
/* nodes live in the SAME space as the carriage: clamped scroll position / max scroll */
tops = secs.map(function(s){ return Math.max(0, Math.min(s.el.offsetTop - 52, maxScroll)); });
tops.forEach(function(tp, i){ nodes[i].style.top = (tp / maxScroll * 100) + "%"; });
}
var carP = 0, target = 0, actLabel = "", raf = null, lastT = 0;
function frame(now){
var rawDt = lastT ? now - lastT : 16;
var dt = Math.min(rawDt, 50);
lastT = now;
var k = rawDt > 400 ? 1 : 1 - Math.exp(-dt / 110);
carP += (target - carP) * k;
if(Math.abs(target - carP) < .0005) carP = target;
car.style.top = (carP * 100) + "%";
read.textContent = actLabel + " · " + Math.round(carP * 100) + "%";
if(carP === target){ raf = null; lastT = 0; return; }
raf = requestAnimationFrame(frame);
}
function upd(){
var doc = document.scrollingElement || document.documentElement;
if(Math.abs(doc.scrollHeight - innerHeight - maxScroll) > 2) layout();
var y = window.scrollY;
target = Math.max(0, Math.min(1, y / maxScroll));
var act = 0;
tops.forEach(function(tp, i){ if(y >= tp - innerHeight * .35) act = i; });
if(maxScroll - y < 4) act = secs.length - 1;
nodes.forEach(function(n, i){ n.classList.toggle("on", i === act); });
actLabel = secs[act].label;
if(raf == null){ lastT = 0; raf = requestAnimationFrame(frame); }
}
addEventListener("scroll", upd, {passive:true});
addEventListener("resize", function(){ layout(); upd(); });
addEventListener("load", function(){ layout(); upd(); });
layout(); upd();
})();
/* ---------- comparison slider ---------- */
(function(){
var cmp = document.getElementById("cmp");
if(!cmp) return;
var old = document.getElementById("cmpOld");
var oldImg = document.getElementById("cmpOldImg");
var handle = document.getElementById("cmpHandle");
var cue = document.getElementById("cmpCue");
var listOld = document.getElementById("cmpListOld");
var listNew = document.getElementById("cmpListNew");
var pos = 50, touched = false;
function fit(){ oldImg.style.width = cmp.getBoundingClientRect().width + "px"; }
function apply(){
old.style.width = pos + "%";
handle.style.left = pos + "%";
handle.setAttribute("aria-valuenow", Math.round(pos));
listOld.style.opacity = Math.max(0, Math.min(1, (pos - 12) / 30));
listNew.style.opacity = Math.max(0, Math.min(1, (88 - pos) / 30));
}
function setFrom(clientX){
var r = cmp.getBoundingClientRect();
pos = Math.max(0, Math.min(100, (clientX - r.left) / r.width * 100));
if(!touched){ touched = true; if(cue) cue.style.opacity = "0"; }
apply();
}
var down = false;
cmp.addEventListener("pointerdown", function(e){
down = true;
cmp.setPointerCapture(e.pointerId);
setFrom(e.clientX);
});
cmp.addEventListener("pointermove", function(e){
/* desktop follows the cursor freely; touch requires a press */
if(down || (finePointer && e.pointerType === "mouse")) setFrom(e.clientX);
});
cmp.addEventListener("pointerup", function(){ down = false; });
cmp.addEventListener("pointercancel", function(){ down = false; });
handle.addEventListener("keydown", function(e){
if(e.key === "ArrowLeft"){ pos = Math.max(0, pos - 4); apply(); e.preventDefault(); }
if(e.key === "ArrowRight"){ pos = Math.min(100, pos + 4); apply(); e.preventDefault(); }
});
if("ResizeObserver" in window) new ResizeObserver(fit).observe(cmp);
else addEventListener("resize", fit);
fit(); apply();
/* one gentle nudge on reveal announces the seam is draggable */
if(!reduced && "IntersectionObserver" in window){
var nio = new IntersectionObserver(function(en){
if(!en[0].isIntersecting) return;
nio.disconnect();
setTimeout(function(){
if(touched) return;
var t0 = null;
(function nf(ts){
if(touched){ pos = 50; apply(); return; }
if(!t0) t0 = ts;
var p = Math.min((ts - t0) / 1200, 1);
pos = 50 + Math.sin(p * Math.PI) * 7;
apply();
if(p < 1) requestAnimationFrame(nf);
})(performance.now());
}, 600);
}, {threshold:.55});
nio.observe(cmp);
}
})();
/* ---------- custom tooltips for the timeline nodes ---------- */
(function(){
var nodes = [].slice.call(document.querySelectorAll("#chaos .tl-node[title]"));
if(!nodes.length) return;
var tip = document.createElement("div");
tip.className = "tip";
document.body.appendChild(tip);
var cur = null;
function hide(){ tip.classList.remove("on"); cur = null; }
function show(n){
cur = n;
tip.textContent = n.dataset.tip;
tip.classList.add("on");
var r = n.getBoundingClientRect();
var tw = tip.offsetWidth, th = tip.offsetHeight;
var x = Math.max(10, Math.min(innerWidth - tw - 10, r.left + r.width / 2 - tw / 2));
var y = r.top - th - 13;
if(y < 70) y = r.bottom + 13;
tip.style.left = x + "px";
tip.style.top = y + "px";
}
nodes.forEach(function(n){
n.dataset.tip = n.getAttribute("title");
n.removeAttribute("title");
if(finePointer){
n.addEventListener("pointerenter", function(){ show(n); });
n.addEventListener("pointerleave", hide);
} else {
/* touch: tap a diamond for its story, tap again or anywhere else to dismiss */
n.addEventListener("click", function(e){
e.stopPropagation();
if(cur === n) hide(); else show(n);
});
}
});
if(!finePointer) addEventListener("click", hide);
addEventListener("scroll", hide, { passive: true });
})();
/* ---------- pricing: monthly / annual ---------- */
(function(){
var bill = document.querySelector(".bill");
if(!bill) return;
var chips = [].slice.call(bill.querySelectorAll(".chip"));
var prices = [].slice.call(document.querySelectorAll(".plan .price .a[data-mo]"));
bill.addEventListener("click", function(ev){
var c = ev.target.closest(".chip");
if(!c) return;
var k = c.dataset.bill;
chips.forEach(function(x){
x.classList.toggle("on", x === c);
x.setAttribute("aria-pressed", x === c ? "true" : "false");
});
prices.forEach(function(a){
a.textContent = "$" + a.dataset[k];
a.classList.remove("pop");
void a.offsetWidth;
a.classList.add("pop");
var b = a.parentElement.querySelector(".b");
if(b) b.textContent = k === "yr" ? "/user/mo · billed annually" : "/user/mo";
});
});
})();
/* ---------- photo lightbox: arrows, dots, Esc / arrow-key support ---------- */
var LB = (function(){
var el = document.getElementById("lb");
if(!el) return { open: function(){}, isOpen: function(){ return false; } };
var img = el.querySelector(".lb-img");
var capB = el.querySelector(".lb-cap b");
var capS = el.querySelector(".lb-cap span");
var count = el.querySelector(".lb-count");
var dots = el.querySelector(".lb-dots");
var items = [], ix = 0, lastFocus = null;
function render(){
var it = items[ix];
img.src = it.src;
img.alt = it.cap + " - " + it.addr;
capB.textContent = it.cap;
capS.textContent = it.addr;
count.textContent = (ix + 1) + " / " + items.length;
[].forEach.call(dots.children, function(d, i){ d.classList.toggle("on", i === ix); });
}
function nav(d){ ix = (ix + d + items.length) % items.length; render(); }
function open(list, i){
items = list;
ix = Math.max(0, Math.min(i || 0, list.length - 1));
dots.innerHTML = "";
list.forEach(function(_, di){
var b = document.createElement("button");
b.type = "button";
b.setAttribute("aria-label", "Photo " + (di + 1));
b.addEventListener("click", function(){ ix = di; render(); });
dots.appendChild(b);
});
lastFocus = document.activeElement;
el.hidden = false;
document.body.classList.add("lb-open");
requestAnimationFrame(function(){ el.classList.add("in"); });
render();
el.querySelector(".lb-x").focus();
}
function close(){
el.classList.remove("in");
document.body.classList.remove("lb-open");
setTimeout(function(){ el.hidden = true; }, 210);
if(lastFocus && lastFocus.focus) lastFocus.focus();
}
el.querySelector(".lb-x").addEventListener("click", close);
el.querySelector(".lb-prev").addEventListener("click", function(){ nav(-1); });
el.querySelector(".lb-next").addEventListener("click", function(){ nav(1); });
el.addEventListener("click", function(e){ if(e.target === el) close(); });
addEventListener("keydown", function(e){
if(el.hidden) return;
if(e.key === "Escape"){ close(); e.preventDefault(); }
if(e.key === "ArrowLeft"){ nav(-1); e.preventDefault(); }
if(e.key === "ArrowRight"){ nav(1); e.preventDefault(); }
});
return { open: open, isOpen: function(){ return !el.hidden; } };
})();
/* ---------- product demo video lightbox ---------- */
(function(){
var el = document.getElementById("vlb");
if(!el) return;
var video = document.getElementById("vlbVideo");
var lastFocus = null;
function open(src){
video.src = src;
lastFocus = document.activeElement;
el.hidden = false;
document.body.classList.add("lb-open");
requestAnimationFrame(function(){ el.classList.add("in"); });
el.querySelector(".lb-x").focus();
video.play().catch(function(){});
}
function close(){
el.classList.remove("in");
document.body.classList.remove("lb-open");
video.pause();
setTimeout(function(){ el.hidden = true; video.removeAttribute("src"); video.load(); }, 210);
if(lastFocus && lastFocus.focus) lastFocus.focus();
}
el.querySelector(".lb-x").addEventListener("click", close);
el.addEventListener("click", function(e){ if(e.target === el) close(); });
addEventListener("keydown", function(e){
if(el.hidden) return;
if(e.key === "Escape"){ close(); e.preventDefault(); }
});
/* the deck IIFE already turns Enter/Space into a synthetic click on these
same cards, so one click listener here covers pointer and keyboard */
[].slice.call(document.querySelectorAll(".feat[data-video]")).forEach(function(f){
f.addEventListener("click", function(){ open(f.dataset.video); });
});
})();
/* ---------- hyper-local intelligence map ---------- */
(function(){
var map = document.getElementById("intelMap");
if(!map) return;
/* ix/iy are fractions of the source photo, anchored to actual structures,
and re-projected through the object-fit:cover crop on every resize */
var spots = [
{ ix:.095, iy:.72, type:"commercial", status:"URGENT", statusCls:"hot", weather:"HAIL EXPECTED",
value:"+$395K", addr:"8333 Douglas Ave, Plano, TX 75024", zone:"Commercial office tower · 9 storeys",
issue:"Roof membrane uplift", specs:"186 sq · TPO · crane staged", risk:"2 hail events / 3 yrs",
photos:[["Office_Roof_Top_View","Roof aerial"],["Broken_Roof_Surface","Membrane damage"]] },
{ ix:.745, iy:.295, type:"commercial", status:"MONITORING", statusCls:"dim", weather:"CLEAR",
value:"+$1.2M", addr:"5600 Tennyson Pkwy, Plano, TX 75024", zone:"Corporate campus · 3 wings",
issue:"Critical membrane failure", specs:"480 sq · mod-bit · garden decks", risk:"20 yrs since replacement",
photos:[["Mall_Rooftop_HVAC_Units","HVAC systems"],["Storm_Worn_Roof","Surface wear"]] },
{ ix:.385, iy:.545, type:"commercial", status:"MONITORING", statusCls:"dim", weather:"CLEAR",
value:"+$520K", addr:"1900 Preston Rd, Plano, TX 75024", zone:"Retail anchor · single storey",
issue:"Ponding water accumulation", specs:"310 sq · ballasted TPO", risk:"Wet insulation · 3 yrs since drainage check",
photos:[["Warehouse_Roof_Aerial","Aerial survey"],["Cracked_Asphalt_Shingles","Substrate cracking"]] },
{ ix:.205, iy:.315, type:"residential", status:"LOW PRIORITY", statusCls:"", weather:"CLEAR",
value:"+$19K", addr:"7212 Covewood Dr, Plano, TX 75024", zone:"Single-family ranch · residential zone",
issue:"Aging composite shingles", specs:"26 sq · 6/12 · laminated comp", risk:"No recent events", fico:"742", tenure:"11 yrs",
photos:[["Sunny_Suburban_House","Street view"],["Curled_Aging_Shingles","Shingle curl"]] },
{ ix:.345, iy:.86, type:"residential", status:"ACTION REQ", statusCls:"warn", weather:"STORM WATCH",
value:"+$74K", addr:"1409 Pecan St, Plano, TX 75024", zone:"Townhome row · 6 units · HOA scope",
issue:"Lifted ridge caps · wind", specs:"6 units · 92 sq · 8/12", risk:"Wind event · 14 days ago", fico:"718", tenure:"6 yrs",
photos:[["Beige_Two_Story_House","Street view"],["Roof_Inspection_Angle","Ridge detail"]] },
{ ix:.13, iy:.42, type:"residential", status:"LOW PRIORITY", statusCls:"", weather:"CLEAR",
value:"+$15K", addr:"812 Kestrel Ln, Plano, TX 75024", zone:"Single-family · residential zone",
issue:"Granule loss · south facets", specs:"22 sq · 4/12 · 3-tab comp", risk:"No recent events", fico:"756", tenure:"14 yrs",
photos:[["Modern_White_Suburban_Home","Street view"],["Residential_Roof_Texture","South facet"]] },
{ ix:.30, iy:.36, type:"storm", status:"URGENT", statusCls:"hot", weather:"HAIL VERIFIED",
value:"+$27K", addr:"950 Ridgeline Rd, Plano, TX 75024", zone:"Storm path · April 22 supercell",
issue:"41 impact points · facet F2", specs:"30 sq · 5/12 · Class-3 comp", risk:"1.75 in hail · verified", fico:"731", tenure:"9 yrs",
photos:[["Brick_Front_Porch_Home","Street view"],["Hail_Damaged_Shingles","Impact bruising"]] },
{ ix:.855, iy:.865, type:"storm", status:"ACTION REQ", statusCls:"warn", weather:"HAIL VERIFIED",
value:"+$140K", addr:"2300 Custer Ct, Plano, TX 75024", zone:"Storm path · multifamily · 3 buildings",
issue:"Soft metal dents · cap sheet scuffing", specs:"3 bldgs · 214 sq · comp + cap sheet", risk:"1.25 in hail · verified",
photos:[["Apartment_Complex","Complex overview"],["Storm_Worn_Roof","Cap sheet wear"]] }
];
var panel = {
el: document.getElementById("intelPanel"), box: document.getElementById("intelBox"),
body: document.getElementById("ipBody"),
tabs: document.getElementById("ipTabs"), photos: document.getElementById("ipPhotos"),
photoN: document.getElementById("ipPhotoN"),
status: document.getElementById("ipStatus"), weather: document.getElementById("ipWeather"),
value: document.getElementById("ipValue"), addr: document.getElementById("ipAddr"),
zone: document.getElementById("ipZone"), issue: document.getElementById("ipIssue"),
specs: document.getElementById("ipSpecs"), risk: document.getElementById("ipRisk"),
owner: document.querySelector(".ip-owner")
};
var img = map.querySelector("img");
var marks = spots.map(function(s){
var b = document.createElement("button");
b.className = "mk " + s.type;
b.setAttribute("aria-label", s.addr);
b.addEventListener("click", function(){ select(s, b); });
map.appendChild(b);
return b;
});
/* project image-space anchors through the cover crop */
var swathEl = map.querySelector(".swath");
var SWQ = [[.16,.22],[.30,.13],[.98,.80],[.86,.95]]; /* corridor containing both storm pins */
function place(){
var cw = map.clientWidth, ch = map.clientHeight;
var iw = img.naturalWidth, ih = img.naturalHeight;
if(!iw || !cw) return;
var sc = Math.max(cw / iw, ch / ih);
var ox = (cw - iw * sc) / 2, oy = (ch - ih * sc) / 2;
function px(p){ return [p[0] * iw * sc + ox, p[1] * ih * sc + oy]; }
spots.forEach(function(s, i){
var x = s.ix * iw * sc + ox, y = s.iy * ih * sc + oy;
var out = x < 14 || x > cw - 14 || y < 14 || y > ch - 14;
marks[i].style.left = x + "px";
marks[i].style.top = y + "px";
marks[i].classList.toggle("crop", out);
});
if(swathEl){
var q = SWQ.map(px);
swathEl.querySelector("polygon").setAttribute("points", q.map(function(p){ return p[0].toFixed(1) + "," + p[1].toFixed(1); }).join(" "));
var ln = swathEl.querySelector("line");
var m1 = [(q[0][0] + q[1][0]) / 2, (q[0][1] + q[1][1]) / 2];
var m2 = [(q[2][0] + q[3][0]) / 2, (q[2][1] + q[3][1]) / 2];
ln.setAttribute("x1", m1[0]); ln.setAttribute("y1", m1[1]);
ln.setAttribute("x2", m2[0]); ln.setAttribute("y2", m2[1]);
var grad = document.getElementById("swathGrad");
if(grad){
grad.setAttribute("x1", m1[0]); grad.setAttribute("y1", m1[1]);
grad.setAttribute("x2", m2[0]); grad.setAttribute("y2", m2[1]);
}
var tp = px([.315, .105]);
var tx = swathEl.querySelector("text");
tx.setAttribute("x", tp[0]); tx.setAttribute("y", tp[1]);
}
}
if(img.complete) place(); else img.addEventListener("load", place);
if("ResizeObserver" in window) new ResizeObserver(place).observe(map);
else addEventListener("resize", place);
function showTab(which){
[].forEach.call(panel.tabs.querySelectorAll("button"), function(b){
var on = b.dataset.tab === which;
b.classList.toggle("on", on);
b.setAttribute("aria-selected", on ? "true" : "false");
});
panel.body.hidden = which !== "details";
panel.photos.hidden = which !== "photos";
}
function select(s, node){
marks.forEach(function(m){ m.classList.remove("sel"); });
node.classList.add("sel");
panel.el.classList.add("open");
panel.box.classList.add("panel-open");
panel.status.textContent = s.status;
panel.status.className = "pill " + s.statusCls;
panel.weather.textContent = s.weather;
panel.value.textContent = s.value;
panel.addr.textContent = s.addr;
panel.zone.textContent = s.zone;
panel.issue.innerHTML = "&#9888; " + s.issue;
panel.specs.textContent = s.specs;
panel.risk.textContent = s.risk;
if(panel.owner) panel.owner.style.display = s.fico ? "" : "none";
panel.photoN.textContent = s.photos.length;
panel.photos.innerHTML = "";
var gallery = s.photos.map(function(p){
return { src: "./img/props/" + p[0] + ".jpg", cap: p[1], addr: s.addr };
});
s.photos.forEach(function(p, pi){
var fig = document.createElement("figure");
fig.className = "ip-ph";
var im = document.createElement("img");
im.src = "./img/props/" + p[0] + ".jpg";
im.alt = p[1] + " - " + s.addr + " (opens full size)";
im.loading = "lazy";
var cap = document.createElement("figcaption");
cap.className = "tag";
cap.textContent = p[1];
fig.appendChild(im); fig.appendChild(cap);
fig.addEventListener("click", function(){ LB.open(gallery, pi); });
panel.photos.appendChild(fig);
});
showTab("details");
}
panel.tabs.addEventListener("click", function(ev){
var b = ev.target.closest("button[data-tab]");
if(b) showTab(b.dataset.tab);
});
document.getElementById("ipClose").addEventListener("click", function(){
marks.forEach(function(m){ m.classList.remove("sel"); });
panel.el.classList.remove("open");
panel.box.classList.remove("panel-open");
});
/* schedule conflict toast */
(function(){
var toast = document.getElementById("conflict");
if(!toast) return;
function dismiss(){ toast.classList.add("gone"); }
document.getElementById("cfDismiss").addEventListener("click", dismiss);
document.getElementById("cfDismissX").addEventListener("click", dismiss);
document.getElementById("cfReschedule").addEventListener("click", function(){
document.getElementById("cfMsg").innerHTML = "Legacy West inspection moved to <b>4:30 PM</b>. Crew and homeowner notified.";
var head = toast.querySelector(".cf-head b");
head.textContent = "Rescheduled";
toast.querySelector(".cf-dot").textContent = "✓";
toast.querySelector(".cf-dot").style.cssText = "background:rgba(34,158,105,.14);color:#229E69";
toast.style.borderLeftColor = "#229E69";
this.remove();
setTimeout(dismiss, 2600);
});
})();
[].slice.call(document.querySelectorAll(".intel-tabs .chip")).forEach(function(tab){
tab.addEventListener("click", function(){
[].forEach.call(document.querySelectorAll(".intel-tabs .chip"), function(t){
t.classList.toggle("on", t === tab);
t.setAttribute("aria-pressed", t === tab ? "true" : "false");
});
var f = tab.dataset.filter;
map.classList.toggle("swath-on", f === "storm");
marks.forEach(function(m, i){
m.classList.toggle("hid", f !== "all" && spots[i].type !== f);
});
});
});
})();
/* ---------- glossary carousel: drag physics + wheel + keys ---------- */
(function(){
var car = document.getElementById("car");
var track = document.getElementById("carTrack");
var prog = document.getElementById("carProg");
if(!car || !track) return;
var x = 0, vx = 0, minX = 0, raf = null, down = false, lastX = 0, lastT = 0, moved = 0;
var goal = null;
function bounds(){
minX = Math.min(0, car.clientWidth - track.scrollWidth);
}
function cardStep(){
var c = track.children[0];
return c ? c.getBoundingClientRect().width + 16 : 328;
}
function apply(){
track.style.transform = "translateX(" + x.toFixed(1) + "px)";
var p = minX < 0 ? x / minX : 0;
if(prog) prog.style.left = (p * 82) + "%";
}
function frame(){
if(!down){
if(goal != null){
/* glide to the requested card seam */
x += (goal - x) * .16;
if(Math.abs(goal - x) < .5){ x = goal; goal = null; vx = 0; }
} else {
x += vx;
vx *= .94;
/* rubber-band back inside the bounds */
if(x > 0){ x += (0 - x) * .18; if(Math.abs(x) < .4 && Math.abs(vx) < .4){ x = 0; vx = 0; } }
else if(x < minX){ x += (minX - x) * .18; if(Math.abs(x - minX) < .4 && Math.abs(vx) < .4){ x = minX; vx = 0; } }
else if(Math.abs(vx) < .8){
/* momentum spent: settle on the nearest card seam */
var st = cardStep();
goal = Math.max(minX, Math.min(0, Math.round(x / st) * st));
vx = 0;
if(Math.abs(goal - x) < .5){ x = goal; goal = null; }
}
}
}
apply();
if(!down && goal == null && Math.abs(vx) < .04 && x <= 0 && x >= minX){ raf = null; return; }
raf = requestAnimationFrame(frame);
}
function wake(){ if(raf == null) raf = requestAnimationFrame(frame); }
function nudge(dir){
var st = cardStep();
var base = goal != null ? goal : Math.round(x / st) * st;
goal = Math.max(minX, Math.min(0, base + dir * st));
vx = 0;
wake();
}
var prevBtn = document.getElementById("carPrev"), nextBtn = document.getElementById("carNext");
if(prevBtn) prevBtn.addEventListener("click", function(){ nudge(1); });
if(nextBtn) nextBtn.addEventListener("click", function(){ nudge(-1); });
car.addEventListener("pointerdown", function(e){
down = true; moved = 0; goal = null;
car.classList.add("dragging");
car.setPointerCapture(e.pointerId);
lastX = e.clientX; lastT = performance.now();
vx = 0;
});
car.addEventListener("pointermove", function(e){
if(!down) return;
var now = performance.now();
var dx = e.clientX - lastX;
moved += Math.abs(dx);
x += dx;
vx = dx * Math.min(1, 16 / Math.max(now - lastT, 1));
lastX = e.clientX; lastT = now;
wake();
});
function up(){ if(!down) return; down = false; car.classList.remove("dragging"); wake(); }
car.addEventListener("pointerup", up);
car.addEventListener("pointercancel", up);
car.addEventListener("click", function(e){ if(moved > 6) e.preventDefault(); }, true);
car.addEventListener("wheel", function(e){
var d = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
if(!d) return;
e.preventDefault();
goal = null;
x -= d;
wake();
}, {passive:false});
addEventListener("keydown", function(e){
if(document.body.classList.contains("lb-open")) return;
if(document.activeElement && car.contains(document.activeElement) || isNear()){
if(e.key === "ArrowLeft"){ nudge(1); }
if(e.key === "ArrowRight"){ nudge(-1); }
}
});
function isNear(){
var r = car.getBoundingClientRect();
return r.top < innerHeight * .8 && r.bottom > innerHeight * .2;
}
if("ResizeObserver" in window) new ResizeObserver(function(){ bounds(); apply(); }).observe(track);
addEventListener("resize", function(){ bounds(); apply(); });
bounds(); apply();
})();
/* ---------- live product reel: center-stage deck ---------- */
(function(){
var stage = document.getElementById("deckStage");
if(!stage) return;
var cards = [].slice.call(stage.children);
var vids = cards.map(function(c){ return c.querySelector("video"); });
var dots = document.getElementById("deckDots");
var cap = document.getElementById("deckCap");
var n = cards.length, ix = 0, touched = false, visible = false;
/* always start from the beginning; runs at 2x by default */
var SPEEDS = [2, 1.5, 1], speedIx = 0;
vids.forEach(function(v){
v.playbackRate = SPEEDS[speedIx];
v.muted = true; /* attributes alone are not enough on some WebKit builds */
v.playsInline = true;
v.addEventListener("loadedmetadata", function(){
v.playbackRate = SPEEDS[speedIx];
});
});
/* mobile autoplay can be denied (low power mode, data saver): surface a real play button */
var playBtn = document.createElement("button");
playBtn.className = "deck-play";
playBtn.setAttribute("aria-label", "Play reel");
playBtn.innerHTML = '<svg viewBox="0 0 20 20" width="22" height="22" aria-hidden="true"><path d="M6 3.5v13l11-6.5z" fill="currentColor"/></svg>';
stage.appendChild(playBtn);
function tryPlay(v){
v.muted = true;
v.playsInline = true;
var p = v.play();
if(p && p.then) p.then(
function(){ stage.classList.remove("blocked"); },
function(){ if(visible) stage.classList.add("blocked"); }
);
}
playBtn.addEventListener("click", function(e){
e.stopPropagation();
touched = true;
/* one gesture unlocks every reel, so later chapters play without another tap */
vids.forEach(function(v, i){
v.muted = true; v.playsInline = true;
var p = v.play();
if(p && p.then) p.then(function(){
if(i !== ix) v.pause();
else stage.classList.remove("blocked");
}, function(){});
});
});
/* subtle corner overlay per reel: play/pause + a cycling speed chip, defaults to 2x.
nested inside each card's own video wrap so it rides along with that card's
carousel transform instead of needing separate position tracking. */
var playGlyph = '<svg viewBox="0 0 20 20" width="13" height="13" aria-hidden="true"><path d="M6 3.5v13l11-6.5z" fill="currentColor"/></svg>';
var pauseGlyph = '<svg viewBox="0 0 20 20" width="13" height="13" aria-hidden="true"><path d="M6 4h3v12H6zM11 4h3v12h-3z" fill="currentColor"/></svg>';
var playGlyphBig = '<svg viewBox="0 0 20 20" width="22" height="22" aria-hidden="true"><path d="M6 3.5v13l11-6.5z" fill="currentColor"/></svg>';
var pauseGlyphBig = '<svg viewBox="0 0 20 20" width="22" height="22" aria-hidden="true"><path d="M6 4h3v12H6zM11 4h3v12h-3z" fill="currentColor"/></svg>';
var ctls = cards.map(function(c, i){
var wrap = c.querySelector(".deck-video-wrap");
var v = vids[i];
/* centered pulse: brief play/pause glyph shown when the video itself is tapped */
var pulseEl = document.createElement("div");
pulseEl.className = "deck-pulse";
pulseEl.setAttribute("aria-hidden", "true");
wrap.appendChild(pulseEl);
var pulseTimer = null;
function pulse(glyph){
pulseEl.innerHTML = glyph;
pulseEl.classList.remove("show");
void pulseEl.offsetWidth; /* restart the animation on repeated taps */
pulseEl.classList.add("show");
clearTimeout(pulseTimer);
pulseTimer = setTimeout(function(){ pulseEl.classList.remove("show"); }, 650);
}
wrap.addEventListener("click", function(e){
if(i !== ix) return; /* prev/next peeks keep switching reels via the card click handler */
e.stopPropagation();
touched = true;
var wasPaused = v.paused;
if(wasPaused) tryPlay(v); else v.pause();
syncControls();
pulse(wasPaused ? playGlyphBig : pauseGlyphBig);
});
var bar = document.createElement("div");
bar.className = "deck-controls";
bar.innerHTML =
'<button class="deck-ctl-btn" type="button" aria-label="Pause"></button>' +
'<div class="deck-ctl-seek" role="slider" tabindex="0" aria-label="Seek" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
'<div class="deck-ctl-seek-track"><div class="deck-ctl-seek-fill"></div><div class="deck-ctl-seek-thumb"></div></div>' +
'</div>' +
'<button class="deck-ctl-speed" type="button" aria-label="Playback speed"></button>';
wrap.appendChild(bar);
var playBtnEl = bar.querySelector(".deck-ctl-btn");
var speedBtnEl = bar.querySelector(".deck-ctl-speed");
var seekEl = bar.querySelector(".deck-ctl-seek");
var seekTrack = bar.querySelector(".deck-ctl-seek-track");
var seekFill = bar.querySelector(".deck-ctl-seek-fill");
var seekThumb = bar.querySelector(".deck-ctl-seek-thumb");
playBtnEl.addEventListener("click", function(e){
e.stopPropagation();
touched = true;
if(v.paused) tryPlay(v); else v.pause();
syncControls();
});
speedBtnEl.addEventListener("click", function(e){
e.stopPropagation();
touched = true;
speedIx = (speedIx + 1) % SPEEDS.length;
vids.forEach(function(vv){ vv.playbackRate = SPEEDS[speedIx]; });
syncControls();
});
/* seekbar: drag or tap to scrub, arrow keys to nudge */
function renderSeek(){
var pct = v.duration ? Math.min(1, Math.max(0, v.currentTime / v.duration)) * 100 : 0;
seekFill.style.width = pct + "%";
seekThumb.style.left = pct + "%";
seekEl.setAttribute("aria-valuenow", Math.round(pct));
}
function seekTo(clientX){
var r = seekTrack.getBoundingClientRect();
if(!r.width || !v.duration) return;
var pct = Math.min(1, Math.max(0, (clientX - r.left) / r.width));
v.currentTime = pct * v.duration;
renderSeek();
}
var dragging = false;
seekEl.addEventListener("pointerdown", function(e){
e.stopPropagation();
touched = true;
dragging = true;
seekEl.setPointerCapture(e.pointerId);
seekTo(e.clientX);
});
seekEl.addEventListener("pointermove", function(e){
if(dragging) seekTo(e.clientX);
});
seekEl.addEventListener("pointerup", function(){ dragging = false; });
seekEl.addEventListener("pointercancel", function(){ dragging = false; });
seekEl.addEventListener("keydown", function(e){
if(!v.duration) return;
var step = v.duration * .02;
if(e.key === "ArrowRight"){ touched = true; v.currentTime = Math.min(v.duration, v.currentTime + step); renderSeek(); e.preventDefault(); e.stopPropagation(); }
if(e.key === "ArrowLeft"){ touched = true; v.currentTime = Math.max(0, v.currentTime - step); renderSeek(); e.preventDefault(); e.stopPropagation(); }
});
v.addEventListener("timeupdate", renderSeek);
v.addEventListener("loadedmetadata", renderSeek);
return { play: playBtnEl, speed: speedBtnEl };
});
function syncControls(){
ctls.forEach(function(c, i){
var v = vids[i];
var playing = v && !v.paused;
c.play.innerHTML = playing ? pauseGlyph : playGlyph;
c.play.setAttribute("aria-label", playing ? "Pause" : "Play");
c.speed.textContent = SPEEDS[speedIx] + "×";
});
}
syncControls();
/* clips stay unfetched until the section approaches */
function loadClips(){
vids.forEach(function(v){
if(!v.src && v.dataset.src){
v.preload = "metadata"; /* fetch the poster frame once we commit to the section */
v.src = v.dataset.src;
}
});
}
if("IntersectionObserver" in window){
var lio = new IntersectionObserver(function(en){
if(en[0].isIntersecting){ lio.disconnect(); loadClips(); apply(); }
}, {rootMargin: "600px 0px"});
lio.observe(stage);
} else loadClips();
/* blueprint figure cards select their chapter */
var featSel = [].slice.call(document.querySelectorAll(".feat[data-reel]"));
function syncFeats(){
featSel.forEach(function(f){
var i = +f.dataset.reel;
var on = i === ix;
f.classList.toggle("on", on);
f.setAttribute("aria-pressed", on ? "true" : "false");
var link = f.querySelector(".feat-link");
if(link) link.innerHTML = on
? 'Now playing <span aria-hidden="true">&#9679;</span>'
: 'Play reel 0' + (i + 1) + ' <span aria-hidden="true">&#9656;</span>';
});
}
featSel.forEach(function(f){
f.addEventListener("click", function(){
touched = true;
go(+f.dataset.reel);
var r = stage.getBoundingClientRect();
if(r.bottom < 80) stage.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "center" });
});
f.addEventListener("keydown", function(e){
if(e.key === "Enter" || e.key === " "){ e.preventDefault(); f.click(); }
});
});
var dotEls = cards.map(function(_, i){
var b = document.createElement("button");
b.setAttribute("role", "tab");
b.setAttribute("aria-label", "Reel " + (i + 1));
b.addEventListener("click", function(){ touched = true; go(i); });
dots.appendChild(b);
return b;
});
function mod(a){ return ((a % n) + n) % n; }
function sizeStage(){
var h = cards[ix].offsetHeight;
if(h > 40) stage.style.height = h + "px";
}
function apply(){
cards.forEach(function(c, i){
c.classList.remove("is-active", "is-prev", "is-next");
if(i === ix) c.classList.add("is-active");
else if(i === mod(ix - 1)) c.classList.add("is-prev");
else if(i === mod(ix + 1)) c.classList.add("is-next");
});
dotEls.forEach(function(d, i){ d.classList.toggle("on", i === ix); });
syncFeats();
if(cap) cap.textContent = "LIVE CAPTURE · REC 0" + (ix + 1) + " / 0" + n;
vids.forEach(function(v, i){
if(i === ix && visible && !reduced) tryPlay(v);
else v.pause();
});
if(!visible) stage.classList.remove("blocked");
sizeStage();
syncControls();
}
function go(i){ ix = mod(i); apply(); }
document.getElementById("deckPrev").addEventListener("click", function(){ touched = true; go(ix - 1); });
document.getElementById("deckNext").addEventListener("click", function(){ touched = true; go(ix + 1); });
cards.forEach(function(c, i){
c.addEventListener("click", function(){
if(i !== ix){ touched = true; go(i); }
});
});
/* swipe */
var sx = null;
stage.addEventListener("pointerdown", function(e){ sx = e.clientX; });
stage.addEventListener("pointerup", function(e){
if(sx == null) return;
var dx = e.clientX - sx;
sx = null;
if(Math.abs(dx) > 44){ touched = true; go(ix + (dx < 0 ? 1 : -1)); }
});
if("IntersectionObserver" in window){
new IntersectionObserver(function(en){
visible = en[0].isIntersecting;
apply();
}, {threshold:.35}).observe(stage);
} else visible = true;
/* projector auto-advances until the user takes the controls,
and never while the cursor is on the deck (someone is watching) */
var watching = false;
var deckBox = document.getElementById("deck");
if(deckBox){
deckBox.addEventListener("pointerenter", function(){ watching = true; });
deckBox.addEventListener("pointerleave", function(){ watching = false; });
}
if(!reduced) setInterval(function(){ if(visible && !touched && !watching) go(ix + 1); }, 9000);
addEventListener("resize", sizeStage);
apply();
})();
/* ---------- CTA mesh-warp shader: the survey grid bends toward the cursor ---------- */
(function(){
var cv = document.getElementById("ctaFx");
if(!cv) return;
var host = cv.parentElement;
var gl = cv.getContext("webgl", {alpha:true, antialias:false, premultipliedAlpha:false});
if(!gl){ cv.remove(); return; }
var dpr = Math.min(devicePixelRatio || 1, 1.5);
function sh(t, s){ var o = gl.createShader(t); gl.shaderSource(o, s); gl.compileShader(o); return o; }
var vs = sh(gl.VERTEX_SHADER, "attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}");
var fs = sh(gl.FRAGMENT_SHADER, [
"precision mediump float;uniform vec2 u_res;uniform float u_t;uniform vec2 u_m;",
"void main(){",
" vec2 uv=gl_FragCoord.xy/u_res;",
" float asp=u_res.x/u_res.y;",
" vec2 p=(uv-.5)*vec2(asp,1.);",
" vec2 m=(u_m-.5)*vec2(asp,1.);",
" vec2 d=p-m;float dist=length(d);",
" float pull=exp(-dist*2.4);",
" vec2 w=p+normalize(d+vec2(1e-4))*pull*.24",
" +vec2(sin(u_t*.10+p.y*1.4),cos(u_t*.08+p.x*1.2))*.018;",
" vec2 g1=abs(fract(w*6.)-.5);",
" float l1=smoothstep(.5,.455,max(g1.x,g1.y));",
" vec2 g2=abs(fract(w*24.)-.5);",
" float l2=smoothstep(.5,.47,max(g2.x,g2.y))*.3;",
" float glow=exp(-dist*3.0);",
" float vg=1.-smoothstep(.42,1.02,length(p));",
" float a=(l1*.36+l2*.16)*(.18+glow*1.05)*vg;",
" vec3 col=vec3(.27,.69,.79)*a+vec3(.95,.66,.23)*glow*.045*vg;",
" gl_FragColor=vec4(col,min(.8,a+glow*.06));",
"}"
].join("\n"));
var pr = gl.createProgram();
gl.attachShader(pr, vs); gl.attachShader(pr, fs); gl.linkProgram(pr); gl.useProgram(pr);
var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
var loc = gl.getAttribLocation(pr, "p");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
var uRes = gl.getUniformLocation(pr, "u_res");
var uT = gl.getUniformLocation(pr, "u_t");
var uM = gl.getUniformLocation(pr, "u_m");
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
function rs(){
var r = host.getBoundingClientRect();
cv.width = Math.max(2, r.width * dpr);
cv.height = Math.max(2, r.height * dpr);
gl.viewport(0, 0, cv.width, cv.height);
}
rs(); addEventListener("resize", rs);
var mx = .5, my = .45, tx = .5, ty = .45, lastMove = 0;
host.addEventListener("pointermove", function(e){
var r = host.getBoundingClientRect();
tx = (e.clientX - r.left) / r.width;
ty = 1 - (e.clientY - r.top) / r.height;
lastMove = performance.now();
});
function paint(t){
gl.uniform2f(uRes, cv.width, cv.height);
gl.uniform1f(uT, t);
gl.uniform2f(uM, mx, my);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
paint(0); /* warm the compile at load, off-screen */
if(reduced){ return; }
var vis = false, t0 = performance.now();
if("IntersectionObserver" in window) new IntersectionObserver(function(en){ vis = en[0].isIntersecting; }).observe(host);
else vis = true;
(function frame(now){
if(vis){
var t = (now - t0) / 1000;
if(now - lastMove > 3000){
tx = .5 + Math.sin(t * .23) * .3;
ty = .48 + Math.cos(t * .17) * .22;
}
mx += (tx - mx) * .05;
my += (ty - my) * .05;
paint(t);
}
requestAnimationFrame(frame);
})(t0);
})();
/* ---------- CTA card glare tracks the cursor ---------- */
if(finePointer && !reduced){
var ctaCard = document.querySelector(".cta");
if(ctaCard){
ctaCard.addEventListener("pointermove", function(e){
var r = ctaCard.getBoundingClientRect();
ctaCard.style.setProperty("--gx", ((e.clientX - r.left) / r.width * 100) + "%");
ctaCard.style.setProperty("--gy", ((e.clientY - r.top) / r.height * 100) + "%");
});
}
}
})();