1491 lines
66 KiB
JavaScript
1491 lines
66 KiB
JavaScript
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'>⚑ 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 — 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 " + o.scanLabel, "<span class='ok'>OK " + o.scanTime + "</span>", null],
|
||
["MEASURE roof " + fmt(mm.roofSqFt) + " sq ft · " + o.pn + "/12 · " + mm.facets + " facets", "<span class='ok'>OK</span>", "roof"],
|
||
["<b>" + (o.shingleLabel || "Architectural shingles") + "</b> · " + squares + " sq", "$" + fmt(shingles), "roof"],
|
||
["<b>Synthetic underlayment</b> · " + rolls + " rolls", "$" + fmt(under), "roof"],
|
||
["<b>Ridge" + (o.style === "hip" || o.style === "mustang" ? " + hip" : "") + " cap</b> · " + mm.ridgeLf + " lf", "$" + fmt(ridge), "ridge"],
|
||
["<b>Drip edge, vents, fasteners</b>", "$" + fmt(misc), "eave"],
|
||
["<b>Tear-off + disposal</b> · " + layers + " layer" + (layers > 1 ? "s" : ""), "$" + fmt(tear), "roof"]
|
||
];
|
||
if(steep) lines.push(["<b>Steep-pitch labor</b> · " + o.pn + "/12 · " + squares + " sq", "$" + fmt(steep), "roof"]);
|
||
if(o.gutters) lines.push(["<b>Gutters</b> · " + 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> · crew of " + o.crew + " · " + 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 " + o.damage + " impacts · facet F2 · severity " + sev, "<span class='flag'>FLAG</span>", "damage"]);
|
||
lines.splice(3, 0, ["<b>Digital Twin evidence pack</b> · " + (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 · 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 · 12 sheets",1560],["Drip edge + valley shield · 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 & water shield · 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 = "⚠ " + 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">●</span>'
|
||
: 'Play reel 0' + (i + 1) + ' <span aria-hidden="true">▸</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) + "%");
|
||
});
|
||
}
|
||
}
|
||
})();
|