(() => { const language = window.TruckStopLanguage || {text: value => value}; const tr = language.text; /* Shared renderer: source image coordinates stay attached to the car at every size. */ function createPolicePatrol(image, rearImage) { const car = {row:0, col:0, sheet:2, spriteRow:0, phase:.20, anchor:[256,272.33], scale:1, police:true, label:'Police patrol', name:'Police patrol'}; function geometry(size) { const width=size*1.05, height=width*2/3; return {sheet:image,sx:0,sy:0,sw:1536,sh:1024,width,height, ax:width*768/1536,ay:height*817/1024}; } function lights(time, reduced=false) { if(reduced) return {red:.22,blue:.22}; const p=((time%1.2)+1.2)%1.2; return {red:p<.09 || (p>=.16&&p<.25)?1:0, blue:(p>=.60&&p<.69)||(p>=.76&&p<.85)?1:0}; } function glow(ctx,x,y,rx,ry,rgb,alpha) { if(!alpha)return; ctx.save();ctx.translate(x,y);ctx.scale(rx,ry); const g=ctx.createRadialGradient(0,0,0,0,0,1); g.addColorStop(0,`rgba(${rgb},${alpha})`); g.addColorStop(.28,`rgba(${rgb},${alpha*.48})`); g.addColorStop(1,`rgba(${rgb},0)`); ctx.fillStyle=g;ctx.beginPath();ctx.arc(0,0,1,0,Math.PI*2);ctx.fill();ctx.restore(); } function lamp(ctx,points,color,rgb,power) { if(!power)return; const [[x1,y1],[x2,y2]]=points; const x=(x1+x2)/2,y=(y1+y2)/2; glow(ctx,x,y,164,118,rgb,power*.92); ctx.save();ctx.lineCap='round';ctx.strokeStyle=color;ctx.lineWidth=27;ctx.globalAlpha=power; ctx.beginPath();ctx.moveTo(x1,y1);ctx.lineTo(x2,y2);ctx.stroke(); ctx.strokeStyle='#fff7ee';ctx.lineWidth=8;ctx.stroke();ctx.restore(); } function draw(ctx,position,size,time,reduced=false) { const sprite = position.policeRear ? rearImage : image; if(!sprite || !sprite.complete || !(sprite.naturalWidth||sprite.width))return; const g=geometry(size),f=position.lightsOn ? lights(time,reduced) : {red:0,blue:0},y=position.groundY??position.y; if(position.policeRear){ ctx.save();ctx.translate(position.x,y);ctx.transform(...position.projection,0,0); ctx.drawImage(sprite,-g.ax,-g.ay,g.width,g.height);ctx.restore();return; } ctx.save();ctx.translate(position.x,y);ctx.transform(...position.projection,0,0); // Small, soft pavement reflections, drawn underneath this vehicle only. ctx.save();ctx.rotate(Math.atan(.35)); glow(ctx,-size*.21,size*.09,size*.36,size*.09,'255,34,45',f.red*.38); glow(ctx,size*.11,size*.08,size*.33,size*.085,'30,114,255',f.blue*.42); ctx.restore(); ctx.drawImage(image,-g.ax,-g.ay,g.width,g.height); ctx.translate(-g.ax,-g.ay);ctx.scale(g.width/1536,g.height/1024); lamp(ctx,[[568,236],[704,205]],'#ff263c','255,30,54',f.red); lamp(ctx,[[725,205],[821,180]],'#288dff','32,115,255',f.blue); ctx.restore(); } return {car,geometry,lights,draw}; } const root = document.getElementById('ts77077-hover'); const stage = root.querySelector('.park-interactive'); const canvas = root.querySelector('.road-traffic'); const camaroLink = root.querySelector('.danger-car-link'); const dangerPopup = root.querySelector('#danger-car-popover'); const dangerVideo = root.querySelector('#danger-video'); const michalLink = root.querySelector('.michal-car-link'); const michalPopup = root.querySelector('#michal-car-popover'); const roaAudio = root.querySelector('#roa-audio'); const roaThemeBtn = root.querySelector('#roa-theme-btn'); let roaThemeExplicit = false; const truckPopover = root.querySelector('#park-truck-popover'); const trafficExposureEl = root.querySelector('#park-traffic-exposure'); const jokePopover = root.querySelector('#joke-car-popover'); const jokePopoverEyebrow = jokePopover ? jokePopover.querySelector('#joke-popover-eyebrow') : null; const jokePopoverText = jokePopover ? jokePopover.querySelector('#joke-popover-text') : null; // Add a dedicated target without renumbering any existing car or control. const policeLink = document.createElement('button'); policeLink.type = 'button'; policeLink.className = 'joke-car-link police-car-link'; policeLink.dataset.carIndex = '8'; policeLink.setAttribute('aria-controls', 'joke-car-popover'); policeLink.setAttribute('aria-expanded', 'false'); policeLink.setAttribute('aria-label', tr('Police patrol — tap for a caption')); policeLink.hidden = true; stage.appendChild(policeLink); const crossoverLink = root.querySelector('.joke-car-link[data-car-index="7"]'); if (crossoverLink) crossoverLink.setAttribute('aria-label', tr('Car passing through — tap for a caption')); const policeImage = new Image(); const policeRearImage = new Image(); const police = createPolicePatrol(policeImage, policeRearImage); const jokeLinkEls = {}; root.querySelectorAll('.joke-car-link').forEach(el => { jokeLinkEls[el.dataset.carIndex] = el; }); const ctx = canvas.getContext('2d'); const controls = []; const toggle = root.querySelector('.traffic-toggle'); const caption = root.querySelector('.traffic-mode-caption'); const reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); const sprites = [new Image(), new Image()]; const sheets = []; let ready = false; let sceneTime = 0; let previousTime = null; let mode = 'gentle'; let paused = reduced.matches; let onScreen = true; let animationId = null; let carHovered = false; let carFocused = false; let carPressed = false; let dangerHideTimer = null; let michalHovered = false; let michalFocused = false; let michalPressed = false; let michalHideTimer = null; let jokeActive = null; let jokeHideTimer = null; const lanes = [{intercept:600, slope:.35}, {intercept:520, slope:.30}]; const cars = [ {row:0, col:1, spriteRow:0, sheet:1, phase:.12, anchor:[681,378], scale:1.02, jokePool:'general'}, {row:0, col:0, spriteRow:0, sheet:1, phase:.34, anchor:[189,379], scale:1.04, name:'Michael Roa BMW X5'}, {row:0, col:2, spriteRow:0, sheet:1, phase:.60, anchor:[1193,377], scale:.96, jokePool:'general'}, {row:1, col:0, spriteRow:0, sheet:0, phase:.855, anchor:[260,365], bodyAngle:Math.atan2(74,205), jokePool:'general'}, {row:1, col:1, spriteRow:0, sheet:0, phase:.675, scale:1.06, anchor:[750,385], bodyAngle:Math.atan2(78,191), jokePool:'general'}, {row:1, col:2, spriteRow:0, sheet:0, phase:.495, name:'Danger Magic Camaro', anchor:[1270,390], bodyAngle:Math.atan2(96,246)}, {row:1, col:0, spriteRow:1, sheet:0, phase:.315, scale:1.07, anchor:[278,800], bodyAngle:Math.atan2(105,220), jokePool:'general'}, {row:1, col:1, spriteRow:1, sheet:0, phase:.135, anchor:[782,815], bodyAngle:Math.atan2(87,200), jokePool:'general'}, {...police.car, jokePool:'police'} ]; cars.forEach((car, index) => { car.idx = index; }); // Joke-caption pools for the unnamed background cars — Danger's dictated // one-liners. One shared pool rotates across the six plain "general" // cars; the added patrol car (flagged jokePool:'police' above) draws only from its // own smaller pool. Both pools are shuffled per-visitor (sessionStorage, // so a reload doesn't reset it, but a new visit gets a fresh shuffle) and // drawn without repeats until exhausted, then reshuffled. const GENERAL_CAPTIONS = [ "Their GPS is secretly sponsored by Truck Stop 77077.", "They’re taking their check-engine light out for a nice dinner at the Truck Stop 77077.", "They missed the entrance to the Truck Stop 77077 and are completing Westheimer’s mandatory six-mile apology lap.", "Their dog is navigating, and he’s made several compelling points about the smell of brisket at the Truck Stop 77077.", "They’re in a high-speed chase. It’s Westheimer, so they’re doing 11.", "They heard Danger makes things disappear and brought their car marriage license.", "His wife is cheating, so he's headed out to grab a taco at the Truck Stop 77077 then kill them both.", "He’s on his way to bail his buddy out. Tacos first—the guy in jail already got fed.", "She’s returning her ex’s belongings in a trash bag. The nice bags are for her takeout from the Truck Stop 77077.", "He called in sick and spotted his boss in line at Truck Stop 77077. They’ve agreed neither of them was here.", "He’s meeting someone from Facebook Marketplace to buy back the TV his girlfriend sold while he was at work.", "His wife and his girlfriend both asked where he is. For once, “getting tacos at the Truck Stop 77077” is the truth.", "She’s on a date with a guy who described himself as an entrepreneur. Apparently, that means she’s buying dinner. So she's taking them to the Truck Stop 77077.", "He came to repossess a truck, but the owner bought him lunch. Negotiations have entered their second taco at the Truck Stop 77077.", "He’s late for a custody exchange. It’s a French bulldog, and somehow the dog has the better lawyer.", "He’s an Uber driver whose passenger asked for a “quick food stop at the Truck Stop 77077.” It’s been forty minutes, and the passenger is now wearing an apron.", "His wife said she hopes he ends up dead in a ditch. He’s getting tacos at the Truck Stop 77077 because that sounds like a long evening.", "His parole officer thinks he’s at work. His boss thinks he’s in jail. He’s at Truck Stop 77077.", "He faked his death to escape his marriage, but his favorite food truck just posted a special. He’s risking it.", "He’s headed to his mother-in-law’s funeral. His wife said to bring something appropriate, so he’s hiding the tacos under a black napkin.", "He’s fleeing the scene, but his passenger keeps saying, “Seriously, you HAVE to try the Truck Stop 77077.”", "His mistress texted “We need to talk.” His wife texted “I know.” He’s putting his phone on airplane mode and ordering dinner at the Truck Stop 77077.", "He’s taking his mother-in-law’s ashes to the beach while he enjoys Hibachi from the Truck Stop 77077. After all these years she’s finally going to sit quietly while he eats.", "He’s meeting the guy blackmailing him. Apparently “come alone and bring the money” now includes “and two tacos, no onions from the Truck Stop 77077.”", "His wife hired a private investigator to follow him. They’re on their third dinner together, and honestly, he feels more supported." ].map(tr); const POLICE_CAPTIONS = [ "“He said the tacos were an emergency. The officer is investigating.”", "“That’s not a pursuit. They’re both trying to get to the Truck Stop 77077.”", "“The suspect is carrying a burrito from the Truck Stop 77077 and should be considered extremely hungry.”", "“He used his turn signal on Westheimer. Naturally, the officer found that suspicious.”", "“We’re providing a police escort to the Truck Stop 77077.”", "“The officer asked if he knew why he was being followed. He said, ‘You smelled the fries?’”", "“They’re cruising on past because Truck Stop 77077 doesn’t have donuts.”" ].map(tr); function makeCaptionRotator(storageKey, items) { function shuffled() { const a = items.map((_, i) => i); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function load() { try { const raw = window.sessionStorage.getItem(storageKey); const parsed = raw ? JSON.parse(raw) : null; if (parsed && Array.isArray(parsed.order) && parsed.order.length === items.length && Number.isInteger(parsed.cursor)) return parsed; } catch (e) {} return {order: shuffled(), cursor: 0}; } function save(state) { try { window.sessionStorage.setItem(storageKey, JSON.stringify(state)); } catch (e) {} } let state = load(); return function next() { if (state.cursor >= state.order.length) { const last = state.order[state.order.length - 1]; let order = shuffled(); while (items.length > 1 && order[0] === last) order = shuffled(); state = {order, cursor: 0}; } const text = items[state.order[state.cursor]]; state.cursor += 1; save(state); return text; }; } const nextGeneralCaption = makeCaptionRotator('ts77077_joke_general_v1', GENERAL_CAPTIONS); const nextPoliceCaption = makeCaptionRotator('ts77077_joke_police_v1', POLICE_CAPTIONS); const RADIO_CYCLE = 2000 / 60; const RADIO_END = 29; function radioPassActive(time) { return Math.floor(Math.max(0, time) / RADIO_CYCLE + 1e-9) % 3 === 2; } function policeInteractionBlocked(time = sceneTime) { return !reduced.matches && !stopDisabled && radioPassActive(time) && radioMotion(time).time < RADIO_END; } const RADIO_LINES = [ {at:12.5, speaker:'Patrol Car', text:'Dispatch, can I get a plate check on “BALLER”?'}, {at:16.5, speaker:'Dispatch', text:'That’s Michael Roa. You can’t pull him over.'}, {at:20, speaker:'Patrol Car', text:'Why not?'}, {at:22, speaker:'Dispatch', text:'He literally owns the road.'} ]; RADIO_LINES.forEach(line => { line.speaker = tr(line.speaker); line.text = tr(line.text); }); let stopDisabled = false; function smoothStep(value) { const t = Math.max(0, Math.min(1, value)); return t * t * (3 - 2 * t); } function radioMotion(time) { const t = ((time % RADIO_CYCLE) + RADIO_CYCLE) % RADIO_CYCLE; // Michael keeps his lane and normal road speed for the entire scene. // Leave a clear 390-unit following gap behind the green SUV. const michaelX = -330 + 60 * t; const specialPass = radioPassActive(time); const closingGap = specialPass ? 460 * (1 - smoothStep((t - 5) / 6)) : 0; const policeX = michaelX - 160 - closingGap; // The cruiser approaches in the empty outside lane, then falls in behind. const policeLane = specialPass ? 82 * (1 - smoothStep((t - 9) / 3)) : 0; return {time:t, michaelX, policeX, michaelLane:0, policeLane}; } function radioLineCount(time) { const t = radioMotion(time).time; return !radioPassActive(time) || t >= RADIO_END ? 0 : RADIO_LINES.filter(line => t >= line.at).length; } function radioCardPlacement(car, width, height, viewport) { const margin = 12, gap = 16; const left = Math.max(viewport.left + margin, Math.min( car.left + car.width / 2 - width / 2, viewport.left + viewport.width - margin - width)); const top = Math.max(viewport.top + margin, car.top - gap - height); return {left, top}; } // Tire-footprint centers relative to each sprite's existing ground anchor. // Keep all artwork and body outlines intact; align the contact footprint, // rather than the roof or the differently padded source-image rectangles. const laneFootprints = [[.148,-.045],[.156,-.052],[.145,-.068], [.016,-.036],[-.021,-.020],[-.0215,.004],[.002,-.015], [.018,-.026],[.009,-.0465]]; function vehicleSize(car, x = car.x) { return car.row === 0 ? (99 + Math.max(0, x) * .020) * 1.5625 * (car.scale || 1) : (94 + Math.max(0, x) * .033) * 1.55 * (car.scale || 1); } function laneCenterY(row, x) { const imageX = x * 1100 / 1672; // Same measured road geometry as the existing road-surface lettering. if (row === 1) return (335.08184 + .3128175 * imageX) * 941 / 619; const yellow = 357.5 + .320 * imageX + .000045 * imageX * imageX; const white = 397 + .432 * imageX; return (yellow + white) / 2 * 941 / 619 + 16; } function positionCar(car, time) { const specialPass = !reduced.matches && !stopDisabled && radioPassActive(time); const policeRear = !!car.police && !specialPass; const row = policeRear ? 1 : car.row; // One shared lap duration keeps the patrol's two quiet passes safely in // the same open gap in the opposite flow. Direction changes occur offscreen. const phase = ((policeRear ? .995 : car.phase) + time * 60 / 2000) % 1; let x = row === 0 ? -180 + phase * 2000 : 1820 - phase * 2000; const projection = policeRear ? [1,-.126,0,1] : row === 0 ? [1, car.police ? -.09 : 0, 0, 1] : [1, lanes[row].slope - Math.tan(car.bodyAngle), 0, 1]; let offset = 0; if (!reduced.matches && !stopDisabled && (car.idx === 1 || (car.police && specialPass))) { const radio = radioMotion(time); x = car.police ? radio.policeX : radio.michaelX; offset = car.police ? radio.policeLane : radio.michaelLane; } const positioned = {...car,row,policeRear,lightsOn:!!car.police && specialPass}; const size = vehicleSize(positioned, x); const footprint = policeRear ? [-.0089,-.048] : laneFootprints[car.idx]; const centerX = x + footprint[0] * size; const centerOffsetY = (footprint[1] + projection[1] * footprint[0]) * size; return {...positioned, x, groundY:laneCenterY(row, centerX) - centerOffsetY + offset, projection}; } // Tight body bounds measured from the opaque vehicle pixels after the same // projection used by draw(). Excludes transparent sprite-cell margins and glow. // Values are relative to each car's ground anchor and multiplied by its size. const carBodyBounds = [[-0.278973,-0.560104,0.61053,0.084923],[-0.305605,-0.523384,0.594948,0.080208],[-0.290023,-0.442129,0.625723,0.078589],[-0.460938,-0.372999,0.439453,0.117831],[-0.455078,-0.470328,0.4375,0.121729],[-0.525391,-0.379421,0.474609,0.130888],[-0.505859,-0.41646,0.492188,0.160156],[-0.472656,-0.431689,0.451172,0.145059],[-0.500391,-0.455656,0.486035,0.0856]]; function vehicleBodyBounds(car) { return car.policeRear ? [-.494,-.440,.501,.080] : carBodyBounds[car.idx]; } let carOutlinePaddingX = 3, carOutlinePaddingY = 3; const outlineStyle = document.createElement('style'); outlineStyle.textContent = ` #ts77077-hover .danger-car-link, #ts77077-hover .michal-car-link, #ts77077-hover .joke-car-link { box-sizing:border-box; border-radius:4px; outline-offset:1px; } #ts77077-hover .danger-car-link:is(:hover,:focus-visible), #ts77077-hover .michal-car-link:is(:hover,:focus-visible), #ts77077-hover .joke-car-link:is(:hover,:focus-visible) { outline-width:2px; outline-offset:1px; }`; root.appendChild(outlineStyle); function positionCarLink(car, size, linkEl) { if (!linkEl) return; const body = vehicleBodyBounds(car); const left = car.x + body[0] * size - carOutlinePaddingX; const top = car.groundY + body[1] * size - carOutlinePaddingY; const right = car.x + body[2] * size + carOutlinePaddingX; const bottom = car.groundY + body[3] * size + carOutlinePaddingY; const clippedLeft = Math.max(0, Math.min(1672, left)); const clippedTop = Math.max(0, Math.min(941, top)); const width = Math.max(0, Math.min(1672, right) - clippedLeft); const height = Math.max(0, Math.min(941, bottom) - clippedTop); linkEl.hidden = width < 1 || height < 1 || (car.police && !(car.policeRear ? policeRearImage : policeImage).naturalWidth); if (car.police) { const blocked = policeInteractionBlocked(); if (linkEl.disabled !== blocked) { linkEl.disabled = blocked; linkEl.style.pointerEvents = blocked ? 'none' : ''; linkEl.tabIndex = blocked ? -1 : 0; linkEl.setAttribute('aria-disabled', String(blocked)); linkEl.setAttribute('aria-label', blocked ? tr('Police radio conversation in progress') : tr('Police patrol — tap for a caption')); } if (blocked && jokeActive === linkEl) closeJokePopup(); } if (linkEl.hidden && jokeActive === linkEl) closeJokePopup(); linkEl.style.left = `${clippedLeft / 1672 * 100}%`; linkEl.style.top = `${clippedTop / 941 * 100}%`; linkEl.style.width = `${width / 1672 * 100}%`; linkEl.style.height = `${height / 941 * 100}%`; } function positionCamaroLink(car, size) { positionCarLink(car, size, camaroLink); } function positionMichalLink(car, size) { positionCarLink(car, size, michalLink); } function positionJokeLink(car, size) { positionCarLink(car, size, jokeLinkEls[car.idx]); } function sizeCanvas() { const bounds = stage.getBoundingClientRect(); // Two CSS pixels of breathing room at desktop and phone widths alike. carOutlinePaddingX = 2 * 1672 / Math.max(1, bounds.width); carOutlinePaddingY = 2 * 941 / Math.max(1, bounds.height); const dpr = Math.min(window.devicePixelRatio || 1, 2); canvas.width = Math.max(1, Math.round(bounds.width * dpr)); canvas.height = Math.max(1, Math.round(bounds.height * dpr)); draw(); positionDangerPopup(); positionMichalPopup(); positionJokePopup(); } function positionDangerPopup() { if (dangerPopup.hidden) return; fitDangerCopy(); const car = camaroLink.getBoundingClientRect(); const width = dangerPopup.offsetWidth, height = dangerPopup.offsetHeight; const vw = document.documentElement.clientWidth, vh = window.innerHeight; let left = car.left + car.width / 2 - width / 2; let top = car.top - height - 12; if (top < 12) top = car.bottom + 12; dangerPopup.style.left = `${Math.max(12, Math.min(vw - width - 12, left))}px`; dangerPopup.style.top = `${Math.max(12, Math.min(vh - height - 12, top))}px`; } function fitDangerCopy() { const title = dangerPopup.querySelector('#danger-title-text'); const subtitle = dangerPopup.querySelector('#danger-subtitle-text'); const available = Math.max(1, dangerPopup.clientWidth - 36); title.style.fontSize = '34px'; const naturalTitleWidth = title.getBoundingClientRect().width; if (!naturalTitleWidth) return; title.style.fontSize = `${34 * Math.min(1, available / naturalTitleWidth)}px`; const titleWidth = title.getBoundingClientRect().width; subtitle.style.fontSize = '28px'; const naturalSubtitleWidth = subtitle.getBoundingClientRect().width; if (naturalSubtitleWidth) subtitle.style.fontSize = `${28 * titleWidth * .94 / naturalSubtitleWidth}px`; } function cancelDangerHide() { if (dangerHideTimer !== null) window.clearTimeout(dangerHideTimer); dangerHideTimer = null; } // First genuine click/key/touch anywhere on the page unlocks unmuted // playback for the rest of the session (Chrome's autoplay-with-sound // policy is granted per-origin the moment a real gesture occurs; Safari // gates it per-element and requires play() to be called synchronously // inside the gesture handler). Hovering alone never counts as a gesture, // so without this, a visitor who hovers the car before clicking anything // gets a muted playback that then sticks muted for the rest of the visit. let mediaUnlocked = false; function unlockMedia() { if (mediaUnlocked) return; mediaUnlocked = true; [roaAudio, dangerVideo].forEach(el => { if (!el) return; if (!el.src) el.src = el.dataset.src; el.muted = true; const attempt = el.play(); if (attempt && attempt.catch) attempt.catch(() => {}); window.setTimeout(() => { el.pause(); try { el.currentTime = 0; } catch (e) {} el.muted = false; }, 60); }); } ['pointerdown', 'keydown', 'touchstart'].forEach(evt => { document.addEventListener(evt, unlockMedia, {once: true, passive: true}); }); function playDangerVideo() { if (!dangerVideo) return; if (!dangerVideo.src) dangerVideo.src = dangerVideo.dataset.src; dangerVideo.muted = false; const attempt = dangerVideo.play(); if (attempt && attempt.catch) { attempt.catch(() => { dangerVideo.muted = true; dangerVideo.play().catch(() => {}); }); } } function pauseDangerVideo() { if (!dangerVideo) return; dangerVideo.pause(); } function showDangerPopup() { cancelDangerHide(); if (!michalPopup.hidden) { michalHovered = michalFocused = michalPressed = false; hideMichalPopup(); } if (jokePopover && !jokePopover.hidden) closeJokePopup(); root.dispatchEvent(new Event('park:danger-details')); dangerPopup.hidden = false; camaroLink.setAttribute('aria-expanded', 'true'); positionDangerPopup(); playDangerVideo(); } function hideDangerPopup() { cancelDangerHide(); dangerPopup.hidden = true; camaroLink.setAttribute('aria-expanded', 'false'); pauseDangerVideo(); } function scheduleDangerHide() { cancelDangerHide(); dangerHideTimer = window.setTimeout(() => { dangerHideTimer = null; if (carHovered || carFocused || dangerPopup.matches(':hover') || dangerPopup.contains(document.activeElement)) return; hideDangerPopup(); }, 200); } // Keep Michael's taller card clear of his BMW: above/below if it fits, // then beside it, then a scrollable card in the larger vertical opening. function michaelCardPlacement(car, width, naturalHeight, viewport) { const margin = 12, gap = 16; const leftEdge = viewport.left + margin, topEdge = viewport.top + margin; const rightEdge = viewport.left + viewport.width - margin; const bottomEdge = viewport.top + viewport.height - margin; const fullHeight = Math.max(1, bottomEdge - topEdge); const height = Math.min(naturalHeight, fullHeight); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); const centeredLeft = clamp(car.left + car.width / 2 - width / 2, leftEdge, rightEdge - width); const above = Math.max(0, Math.min(fullHeight, car.top - gap - topEdge)); const below = Math.max(0, Math.min(fullHeight, bottomEdge - car.bottom - gap)); if (height <= above) return {left:centeredLeft, top:Math.min(bottomEdge, car.top - gap) - height, maxHeight:fullHeight, placement:'above'}; if (height <= below) return {left:centeredLeft, top:Math.max(topEdge, car.bottom + gap), maxHeight:fullHeight, placement:'below'}; const centeredTop = clamp(car.top + car.height / 2 - height / 2, topEdge, bottomEdge - height); if (rightEdge - car.right - gap >= width) return {left:Math.max(leftEdge, car.right + gap), top:centeredTop, maxHeight:fullHeight, placement:'right'}; if (car.left - gap - leftEdge >= width) return {left:Math.min(rightEdge - width, car.left - gap - width), top:centeredTop, maxHeight:fullHeight, placement:'left'}; const useAbove = above >= below; const maxHeight = Math.max(1, useAbove ? above : below); return {left:centeredLeft, top:useAbove ? Math.max(topEdge, car.top - gap - Math.min(naturalHeight, maxHeight)) : Math.max(topEdge, car.bottom + gap), maxHeight, placement:useAbove ? 'above' : 'below'}; } function positionMichalPopup() { if (michalPopup.hidden) return; const car = michalLink.getBoundingClientRect(); const visual = window.visualViewport; const viewport = { left:visual ? visual.offsetLeft : 0, top:visual ? visual.offsetTop : 0, width:visual ? visual.width : document.documentElement.clientWidth, height:visual ? visual.height : window.innerHeight }; // scrollHeight preserves the full content measurement when the card is // height-limited, without expanding it or resetting the visitor's scroll. const border = michalPopup.offsetHeight - michalPopup.clientHeight; const naturalHeight = michalPopup.scrollHeight + border; const placement = michaelCardPlacement(car, michalPopup.offsetWidth, naturalHeight, viewport); michalPopup.style.maxHeight = `${placement.maxHeight}px`; michalPopup.style.left = `${placement.left}px`; michalPopup.style.top = `${placement.top}px`; michalPopup.dataset.placement = placement.placement; } function cancelMichalHide() { if (michalHideTimer !== null) window.clearTimeout(michalHideTimer); michalHideTimer = null; } // Joke-car popover (shared singleton across the rotating background cars). function cancelJokeHide() { if (jokeHideTimer !== null) window.clearTimeout(jokeHideTimer); jokeHideTimer = null; } function positionJokePopup() { if (!jokePopover || jokePopover.hidden || !jokeActive) return; const tag = jokeActive.getBoundingClientRect(); const width = jokePopover.offsetWidth, height = jokePopover.offsetHeight; const vw = document.documentElement.clientWidth, vh = window.innerHeight; let left = tag.left + tag.width / 2 - width / 2; let top = tag.top - height - 12; if (top < 12) top = tag.bottom + 12; jokePopover.style.left = `${Math.max(12, Math.min(vw - width - 12, left))}px`; jokePopover.style.top = `${Math.max(12, Math.min(vh - height - 12, top))}px`; } function openJokePopup(target, car) { if (!jokePopover || !target || target.hidden || (car.police && policeInteractionBlocked())) return; cancelJokeHide(); if (!dangerPopup.hidden) { carHovered = carFocused = carPressed = false; hideDangerPopup(); } if (!michalPopup.hidden) { michalHovered = michalFocused = michalPressed = false; hideMichalPopup(); } if (truckPopover && !truckPopover.hidden) truckPopover.hidden = true; root.dispatchEvent(new Event('park:danger-details')); jokeActive = target; const isPolice = car.jokePool === 'police'; if (jokePopoverEyebrow) jokePopoverEyebrow.textContent = isPolice ? tr('On Patrol') : tr('Spotted on Westheimer'); if (jokePopoverText) jokePopoverText.textContent = isPolice ? nextPoliceCaption() : nextGeneralCaption(); jokePopover.hidden = false; target.setAttribute('aria-expanded', 'true'); positionJokePopup(); } function closeJokePopup() { cancelJokeHide(); if (jokeActive) jokeActive.setAttribute('aria-expanded', 'false'); jokeActive = null; if (jokePopover) jokePopover.hidden = true; } function scheduleJokeHide() { cancelJokeHide(); jokeHideTimer = window.setTimeout(() => { jokeHideTimer = null; if (!jokePopover) return; if (jokePopover.matches(':hover') || jokePopover.contains(document.activeElement)) return; if (jokeActive && (jokeActive.matches(':hover') || jokeActive === document.activeElement)) return; closeJokePopup(); }, 200); } // Road-surface traffic-stat overlay ("58,000 CARS PER DAY") — painted // directly onto the road using the same lane geometry as the cars above, // revealed any time an available-space popup is active and showing its // traffic-exposure callout. Cars are drawn after this in draw(), so they // occlude the lettering exactly like real traffic passing over paint. let overlayProgress = 0, overlayTarget = 0; let lettering = null, roadDecal = null; // Canvas lettering follows the active TranslatePress page language. const roadIsSpanish = /^es(?:-|_|$)/i.test(document.documentElement.lang) || /^\/es(?:\/|$)/i.test(window.location.pathname); const roadCopy = roadIsSpanish ? {heading:'TRÁFICO EN WESTHEIMER', unit:'AUTOS AL DÍA'} : {heading:'WESTHEIMER TRAFFIC', unit:'CARS PER DAY'}; function makeLettering() { const c = document.createElement('canvas'); c.width = 790; c.height = 176; const lctx = c.getContext('2d'); lctx.font = '700 64px "Barlow Condensed"'; lctx.fillStyle = '#fff5d8'; lctx.fillText(roadCopy.heading, 0, 54, c.width); lctx.font = '700 118px "Barlow Condensed"'; lctx.fillStyle = '#ffbd24'; lctx.fillText('58,000', 0, 153); const numberWidth = lctx.measureText('58,000').width; lctx.font = '700 55px "Barlow Condensed"'; lctx.fillStyle = '#fff5d8'; lctx.fillText(roadCopy.unit, numberWidth + 24, 135.5, c.width - numberWidth - 24); return c; } // Calibrated to the double-yellow markings in the reference artwork // (measured at 1100 x 619; scaled here to the live canvas's 1672 x 941). const roadRows = [{start:0, end:62, name:'heading'}, {start:62, end:176, name:'count'}]; function roadPoint(u, sourceY, row) { const v = sourceY - 164; const x = 245 + 1.08 * u - .72 * v; const imageX = x * 1100 / 1672; const centerY = (357.5 + .320 * imageX + .000045 * imageX * imageX) * 941 / 619; if (row === 'count') { const whiteLineY = (397 + .432 * imageX) * 941 / 619; const laneMiddle = (centerY + whiteLineY) / 2; return [x, laneMiddle + .86 * (sourceY - 116.5)]; } const headingMiddle = (335.08184 + .3128175 * imageX) * 941 / 619; return [x, headingMiddle + .86 * (sourceY - 31.5)]; } function makeRoadDecal() { const decal = document.createElement('canvas'); decal.width = 1672; decal.height = 941; const dctx = decal.getContext('2d'); for (const row of roadRows) { for (let u = 0; u < lettering.width; u += 20) { const width = Math.min(20.4, lettering.width - u), height = row.end - row.start; const a = roadPoint(u, row.start, row.name), b = roadPoint(u + width, row.start, row.name), d = roadPoint(u, row.end, row.name); dctx.setTransform((b[0]-a[0])/width, (b[1]-a[1])/width, (d[0]-a[0])/height, (d[1]-a[1])/height, a[0], a[1]); dctx.drawImage(lettering, u, row.start, width, height, 0, 0, width, height); } } return decal; } function drawOverlay(overlayCtx, p) { if (p < .005 || !roadDecal) return; overlayCtx.save(); overlayCtx.globalAlpha = p * .97; overlayCtx.beginPath(); const end = lettering.width * Math.min(1, p); for (const row of roadRows) { for (let i = 0; i <= 24; i++) { const point = roadPoint(end * i / 24, row.start, row.name); if (i === 0) overlayCtx.moveTo(...point); else overlayCtx.lineTo(...point); } for (let i = 24; i >= 0; i--) overlayCtx.lineTo(...roadPoint(end * i / 24, row.end, row.name)); overlayCtx.closePath(); } overlayCtx.clip(); overlayCtx.drawImage(roadDecal, 0, 0); overlayCtx.restore(); } function overlayAnimating() { return Math.abs(overlayTarget - overlayProgress) > .0008; } Promise.all([ document.fonts.load('700 64px "Barlow Condensed"'), document.fonts.load('700 118px "Barlow Condensed"'), document.fonts.load('700 55px "Barlow Condensed"') ]).catch(() => {}).then(() => { lettering = makeLettering(); roadDecal = makeRoadDecal(); }); function playRoaAudio() { if (!roaAudio) return; // Once the visitor has taken explicit control with the theme button // (to play OR to pause), ambient hover play/pause stays hands-off until // the track ends naturally. Without this, hovering the car again after // an explicit pause immediately restarts the song, which is what made // the button feel like it was jittering / not holding its paused state. if (roaThemeExplicit) return; if (!roaAudio.src) roaAudio.src = roaAudio.dataset.src; roaAudio.muted = false; const attempt = roaAudio.play(); if (attempt && attempt.catch) { attempt.catch(() => { roaAudio.muted = true; roaAudio.play().catch(() => {}); }); } } function pauseRoaAudio() { if (!roaAudio) return; // A visitor who pressed "Play Michael's Theme" gets the full song, // even after they stop hovering the car or close the popover — only // the theme button itself (or the track ending) should stop it. if (roaThemeExplicit) return; roaAudio.pause(); } function showMichalPopup() { cancelMichalHide(); if (!dangerPopup.hidden) { carHovered = carFocused = carPressed = false; hideDangerPopup(); } if (jokePopover && !jokePopover.hidden) closeJokePopup(); root.dispatchEvent(new Event('park:danger-details')); michalPopup.hidden = false; michalLink.setAttribute('aria-expanded', 'true'); positionMichalPopup(); playRoaAudio(); } function hideMichalPopup() { cancelMichalHide(); michalPopup.hidden = true; michalLink.setAttribute('aria-expanded', 'false'); pauseRoaAudio(); } function scheduleMichalHide() { cancelMichalHide(); michalHideTimer = window.setTimeout(() => { michalHideTimer = null; if (michalHovered || michalFocused || michalPopup.matches(':hover') || michalPopup.contains(document.activeElement)) return; hideMichalPopup(); }, 200); } function prepareSprites(sprite, index) { const keyed = document.createElement('canvas'); keyed.width = sprite.naturalWidth; keyed.height = sprite.naturalHeight; const keyCtx = keyed.getContext('2d', {willReadFrequently:true}); keyCtx.drawImage(sprite, 0, 0); const pixels = keyCtx.getImageData(0, 0, keyed.width, keyed.height); for (let i = 0; i < pixels.data.length; i += 4) { const r = pixels.data[i], g = pixels.data[i + 1], b = pixels.data[i + 2]; const excessGreen = g - Math.max(r, b); if (g > 90 && excessGreen > 25) { const removal = Math.max(0, Math.min(1, (excessGreen - 25) / 95)); pixels.data[i + 3] = Math.round(pixels.data[i + 3] * (1 - removal)); if (removal > 0) pixels.data[i + 1] = Math.min(g, Math.max(r, b) + 12); } } keyCtx.putImageData(pixels, 0, 0); sheets[index] = keyed; if (sheets.filter(Boolean).length === sprites.length) { ready = true; sizeCanvas(); startLoop(); } } function draw() { if (!ctx) return; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, canvas.width, canvas.height); if (!ready) return; ctx.setTransform(canvas.width / 1672, 0, 0, canvas.height / 941, 0, 0); drawOverlay(ctx, overlayProgress); const positions = cars.map(car => positionCar(car, sceneTime)).sort((a,b) => a.groundY - b.groundY); positions.forEach(car => { const size = vehicleSize(car); if (car.name === 'Danger Magic Camaro') positionCamaroLink(car, size); if (car.name === 'Michael Roa BMW X5') positionMichalLink(car, size); if (car.jokePool) positionJokeLink(car, size); const body = vehicleBodyBounds(car); if (car.x + body[2] * size < 0 || car.x + body[0] * size > 1672 || car.groundY + body[3] * size < 0 || car.groundY + body[1] * size > 941) return; if (car.police) { police.draw(ctx, car, size, sceneTime, reduced.matches); return; } const sheet = sheets[car.sheet]; const spriteRow = car.spriteRow ?? car.row; const cellH = car.sheet === 1 ? sheet.height : sheet.height / 2; const edges = car.sheet === 0 ? (spriteRow === 0 ? [0,508,992,1536] : [0,534,1040,1536]) : [0,512,1024,1536]; const sourceX = edges[car.col] * sheet.width / 1536; const sourceWidth = (edges[car.col + 1] - edges[car.col]) * sheet.width / 1536; const width = size * sourceWidth / cellH; ctx.save(); ctx.translate(car.x, car.groundY); ctx.transform(...car.projection, 0, 0); const anchorX = (car.anchor[0] - edges[car.col]) * size / 512; const anchorY = (car.anchor[1] - spriteRow * 512) * size / 512; ctx.drawImage(sheet, sourceX, spriteRow * cellH, sourceWidth, cellH, -anchorX, -anchorY, width, size); ctx.restore(); }); syncStopCaption(); } const stopCaption = document.createElement('aside'); stopCaption.className = 'police-stop-caption'; stopCaption.setAttribute('aria-label', tr('Police radio conversation')); stopCaption.setAttribute('data-no-translation',''); stopCaption.hidden = true; const radioEntries = RADIO_LINES.map(line => { const entry = document.createElement('div'); entry.className = 'radio-line'; entry.setAttribute('aria-hidden', 'true'); const paragraph = document.createElement('p'); const content = document.createElement('span'); const speaker = document.createElement('strong'); speaker.textContent = line.speaker + ': '; content.append(speaker, document.createTextNode(line.text)); paragraph.appendChild(content); entry.appendChild(paragraph); stopCaption.appendChild(entry); return entry; }); root.appendChild(stopCaption); const stopStyle = document.createElement('style'); stopStyle.textContent = ` #ts77077-hover .police-stop-caption {position:fixed;z-index:90;box-sizing:border-box;width:360px;max-width:calc(100vw - 24px);padding:15px 17px;background:#fff5d8;color:#062f4f;border:2px solid #062f4f;border-top:5px solid #ffbd24;border-radius:9px;box-shadow:0 10px 30px #021d3255;font:14px/1.5 "DM Sans",Arial,sans-serif;text-align:left;pointer-events:none;} #ts77077-hover .police-stop-caption[hidden] {display:none;} #ts77077-hover .police-stop-caption .radio-line {display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .25s ease,opacity .2s ease;} #ts77077-hover .police-stop-caption .radio-line.is-visible {grid-template-rows:1fr;opacity:1;} #ts77077-hover .police-stop-caption .radio-line p {min-height:0;overflow:hidden;margin:0;color:#062f4f;-webkit-text-fill-color:#062f4f;text-shadow:none;font:inherit;} #ts77077-hover .police-stop-caption .radio-line p > span {display:block;padding-top:10px;} #ts77077-hover .police-stop-caption .radio-line:first-child p > span {padding-top:0;} #ts77077-hover .police-stop-caption strong {display:inline;margin:0;color:#062f4f;-webkit-text-fill-color:#062f4f;font:800 14px/1.5 "DM Sans",Arial,sans-serif;letter-spacing:0;text-transform:none;text-shadow:none;} @media (prefers-reduced-motion:reduce) {#ts77077-hover .police-stop-caption .radio-line {transition:none;}} `; root.appendChild(stopStyle); let visibleRadioLines = 0; function syncStopCaption() { const count = radioLineCount(sceneTime); if (count !== visibleRadioLines) { radioEntries.forEach((entry,index) => { entry.classList.toggle('is-visible', index < count); entry.setAttribute('aria-hidden', String(index >= count)); }); visibleRadioLines = count; } const retail = root.querySelector('#retail-popover'); const blocked = !dangerPopup.hidden || !michalPopup.hidden || (jokePopover && !jokePopover.hidden) || (truckPopover && !truckPopover.hidden) || (retail && !retail.hidden); stopCaption.hidden = reduced.matches || stopDisabled || !policeImage.naturalWidth || !onScreen || count === 0 || blocked; if (stopCaption.hidden) return; const car = policeLink.getBoundingClientRect(); const visual = window.visualViewport; const viewport = {left:visual ? visual.offsetLeft : 0, top:visual ? visual.offsetTop : 0, width:visual ? visual.width : document.documentElement.clientWidth, height:visual ? visual.height : window.innerHeight}; if (!car.width || car.bottom < viewport.top || car.top > viewport.top + viewport.height || car.right < viewport.left || car.left > viewport.left + viewport.width) { stopCaption.hidden = true; return; } // Keep the bottom of the growing conversation just above the moving cruiser. const placement = radioCardPlacement(car, stopCaption.offsetWidth, stopCaption.offsetHeight, viewport); stopCaption.style.left = `${placement.left}px`; stopCaption.style.top = `${placement.top}px`; } // This caption is decorative; it never steals focus or pauses the other cars. [dangerPopup,michalPopup,jokePopover,truckPopover,root.querySelector('#retail-popover')].filter(Boolean).forEach(el => { new MutationObserver(syncStopCaption).observe(el, {attributes:true,attributeFilter:['hidden']}); }); window.addEventListener('scroll', syncStopCaption, true); window.addEventListener('resize', syncStopCaption); function moving() { return !paused && dangerPopup.hidden && michalPopup.hidden && (!jokePopover || jokePopover.hidden) && !carHovered && !carFocused && !carPressed && !michalHovered && !michalFocused && !michalPressed && mode !== 'still' && (mode === 'gentle' || Boolean(root.dataset.activeVacancy)); } function tick(now) { animationId = null; if (previousTime !== null && onScreen && !document.hidden) { const dt = Math.min(now - previousTime, 70) / 1000; const wasOverlayAnimating = overlayAnimating(); if (moving()) sceneTime += dt; if (wasOverlayAnimating) overlayProgress += (overlayTarget - overlayProgress) * (1 - Math.exp(-dt * (overlayTarget ? 9 : 7))); if (moving() || wasOverlayAnimating) draw(); } previousTime = now; if (onScreen && !document.hidden) animationId = requestAnimationFrame(tick); } function startLoop() { if (animationId === null && ready && onScreen && !document.hidden) { previousTime = null; animationId = requestAnimationFrame(tick); } } function updateControls() { if (!toggle || !caption) return; toggle.hidden = mode === 'still'; toggle.textContent = paused ? tr('Play traffic') : tr('Pause traffic'); toggle.setAttribute('aria-pressed', String(!paused)); caption.textContent = mode === 'hover' ? tr('Hover an open-space tag to bring the road to life.') : tr('Hover open-space tags for traffic & leasing details.'); } let lastPointerX = null, lastPointerY = null; document.addEventListener('pointermove', event => { if (event.pointerType !== 'mouse') return; lastPointerX = event.clientX; lastPointerY = event.clientY; }, {passive: true}); function pointInRect(x, y, rect) { return x !== null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; } function watchdogTick() { if (lastPointerX === null) return; if (!dangerPopup.hidden) { const overLink = pointInRect(lastPointerX, lastPointerY, camaroLink.getBoundingClientRect()); const overPopup = pointInRect(lastPointerX, lastPointerY, dangerPopup.getBoundingClientRect()); if (!overLink && !overPopup) { carHovered = carFocused = carPressed = false; hideDangerPopup(); } } if (!michalPopup.hidden) { const overLink = pointInRect(lastPointerX, lastPointerY, michalLink.getBoundingClientRect()); const overPopup = pointInRect(lastPointerX, lastPointerY, michalPopup.getBoundingClientRect()); if (!overLink && !overPopup) { michalHovered = michalFocused = michalPressed = false; hideMichalPopup(); } } if (jokePopover && !jokePopover.hidden && jokeActive) { const overLink = pointInRect(lastPointerX, lastPointerY, jokeActive.getBoundingClientRect()); const overPopup = pointInRect(lastPointerX, lastPointerY, jokePopover.getBoundingClientRect()); if (!overLink && !overPopup) closeJokePopup(); } } setInterval(watchdogTick, 400); if (toggle) toggle.addEventListener('click', () => { paused = !paused; updateControls(); startLoop(); }); camaroLink.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') { carHovered = true; showDangerPopup(); } }); camaroLink.addEventListener('pointerleave', () => { carHovered = false; carPressed = false; scheduleDangerHide(); }); camaroLink.addEventListener('focus', () => { carFocused = true; showDangerPopup(); }); camaroLink.addEventListener('blur', () => { carFocused = false; scheduleDangerHide(); }); let carTouchTap = false; camaroLink.addEventListener('pointerdown', () => { carPressed = true; }); camaroLink.addEventListener('pointerup', event => { carPressed = false; if (event.pointerType === 'touch') carTouchTap = true; }); camaroLink.addEventListener('pointercancel', () => { carPressed = false; carTouchTap = false; }); camaroLink.addEventListener('click', event => { if (!carTouchTap) return; carTouchTap = false; event.preventDefault(); showDangerPopup(); }); dangerPopup.addEventListener('pointerenter', cancelDangerHide); dangerPopup.addEventListener('pointerleave', scheduleDangerHide); dangerPopup.addEventListener('focusin', cancelDangerHide); dangerPopup.addEventListener('focusout', scheduleDangerHide); dangerPopup.querySelector('.popover-close').addEventListener('click', () => { carHovered = carFocused = carPressed = false; hideDangerPopup(); }); michalLink.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') { michalHovered = true; showMichalPopup(); } }); michalLink.addEventListener('pointerleave', () => { michalHovered = false; michalPressed = false; scheduleMichalHide(); }); michalLink.addEventListener('focus', () => { michalFocused = true; showMichalPopup(); }); michalLink.addEventListener('blur', () => { michalFocused = false; scheduleMichalHide(); }); let michalTouchTap = false; michalLink.addEventListener('pointerdown', () => { michalPressed = true; }); michalLink.addEventListener('pointerup', event => { michalPressed = false; if (event.pointerType === 'touch') michalTouchTap = true; }); michalLink.addEventListener('pointercancel', () => { michalPressed = false; michalTouchTap = false; }); michalLink.addEventListener('click', event => { if (!michalTouchTap) { // Mouse click: let the link navigate normally, but a real click is // exactly the gesture browsers require before they'll play sound — // use it to guarantee Michael's line plays even if this is the very // first thing the visitor has clicked on the page. playRoaAudio(); return; } michalTouchTap = false; event.preventDefault(); showMichalPopup(); }); if (roaThemeBtn && roaAudio) { const themeIcon = roaThemeBtn.querySelector('.roa-theme-icon'); const themeLabel = roaThemeBtn.querySelector('.roa-theme-label'); function setThemeButtonState(playing) { roaThemeBtn.classList.toggle('is-playing', playing); roaThemeBtn.setAttribute('aria-pressed', playing ? 'true' : 'false'); if (themeIcon) themeIcon.textContent = playing ? '⏸' : '▶'; if (themeLabel) themeLabel.textContent = playing ? tr("Pause Michael’s Theme") : tr("Play Michael’s Theme"); } setThemeButtonState(!roaAudio.paused); roaThemeBtn.addEventListener('click', () => { if (!roaAudio.src) roaAudio.src = roaAudio.dataset.src; // Either action here — play or pause — is the visitor taking manual // control, so hover stays out of the way in both directions until the // track ends (see the guard in playRoaAudio/pauseRoaAudio above). roaThemeExplicit = true; if (roaAudio.paused) { roaAudio.muted = false; roaAudio.play().catch(() => {}); } else { roaAudio.pause(); } }); roaAudio.addEventListener('play', () => setThemeButtonState(true)); roaAudio.addEventListener('pause', () => setThemeButtonState(false)); roaAudio.addEventListener('ended', () => { roaThemeExplicit = false; setThemeButtonState(false); }); } if (truckPopover) { // The overlay tracks the popup's actual open/closed + exposure-shown // state directly (not just mouseover), so it lights up the moment any // empty-space popup becomes active — however it opened (hover, focus, // touch) — and drops the instant it closes for any reason. const exposureVisible = () => trafficExposureEl ? !trafficExposureEl.hidden : false; const syncOverlay = () => { overlayTarget = (!truckPopover.hidden && exposureVisible()) ? 1 : 0; }; new MutationObserver(syncOverlay).observe(truckPopover, {attributes: true, attributeFilter: ['hidden']}); if (trafficExposureEl) new MutationObserver(syncOverlay).observe(trafficExposureEl, {attributes: true, attributeFilter: ['hidden']}); syncOverlay(); } michalPopup.addEventListener('pointerenter', cancelMichalHide); michalPopup.addEventListener('pointerleave', scheduleMichalHide); michalPopup.addEventListener('focusin', cancelMichalHide); michalPopup.addEventListener('focusout', scheduleMichalHide); michalPopup.querySelector('.popover-close').addEventListener('click', () => { michalHovered = michalFocused = michalPressed = false; hideMichalPopup(); }); root.addEventListener('park:truck-details', () => { carHovered = carFocused = carPressed = false; hideDangerPopup(); michalHovered = michalFocused = michalPressed = false; hideMichalPopup(); if (jokePopover && !jokePopover.hidden) closeJokePopup(); }); Object.keys(jokeLinkEls).forEach(key => { const el = jokeLinkEls[key]; const car = cars[Number(key)]; if (!el || !car) return; el.addEventListener('pointerenter', event => { if (event.pointerType === 'mouse') openJokePopup(el, car); }); el.addEventListener('pointerleave', event => { if (event.pointerType === 'mouse') scheduleJokeHide(); }); el.addEventListener('focus', () => openJokePopup(el, car)); el.addEventListener('blur', scheduleJokeHide); el.addEventListener('click', () => openJokePopup(el, car)); }); if (jokePopover) { jokePopover.addEventListener('pointerenter', cancelJokeHide); jokePopover.addEventListener('pointerleave', scheduleJokeHide); jokePopover.addEventListener('focusin', cancelJokeHide); jokePopover.addEventListener('focusout', scheduleJokeHide); const jokeClose = jokePopover.querySelector('.popover-close'); if (jokeClose) jokeClose.addEventListener('click', closeJokePopup); } document.addEventListener('keydown', event => { if (event.key === 'Escape') { if (!dangerPopup.hidden) { carHovered = carFocused = carPressed = false; hideDangerPopup(); } if (!michalPopup.hidden) { michalHovered = michalFocused = michalPressed = false; hideMichalPopup(); } if (jokePopover && !jokePopover.hidden) closeJokePopup(); } }); document.addEventListener('pointerdown', event => { if (!dangerPopup.contains(event.target) && !camaroLink.contains(event.target)) { carHovered = carFocused = carPressed = false; hideDangerPopup(); } if (!michalPopup.contains(event.target) && !michalLink.contains(event.target)) { michalHovered = michalFocused = michalPressed = false; hideMichalPopup(); } if (jokePopover && !jokePopover.hidden && !jokePopover.contains(event.target) && !(jokeActive && jokeActive.contains(event.target))) { closeJokePopup(); } }); window.addEventListener('scroll', () => { positionDangerPopup(); positionMichalPopup(); positionJokePopup(); }, true); window.addEventListener('resize', () => { positionDangerPopup(); positionMichalPopup(); positionJokePopup(); }); if (window.visualViewport) { window.visualViewport.addEventListener('resize', positionMichalPopup); window.visualViewport.addEventListener('scroll', positionMichalPopup); } if (document.fonts) document.fonts.ready.then(() => { positionDangerPopup(); positionMichalPopup(); positionJokePopup(); }); reduced.addEventListener('change', event => { paused = event.matches; updateControls(); startLoop(); }); new ResizeObserver(sizeCanvas).observe(stage); new IntersectionObserver(entries => { onScreen = entries[0].isIntersecting; if (!onScreen) stopCaption.hidden = true; if (!onScreen && animationId !== null) { cancelAnimationFrame(animationId); animationId = null; previousTime = null; } else startLoop(); }, {threshold:0}).observe(stage); document.addEventListener('visibilitychange', () => { if (document.hidden && animationId !== null) { cancelAnimationFrame(animationId); animationId = null; previousTime = null; } else startLoop(); }); sprites.forEach((sprite, index) => sprite.addEventListener('load', () => prepareSprites(sprite, index))); sprites[0].src = 'https://www.truckstop77077.com/wp-content/uploads/2026/09/park-layout-sprite-2.png'; sprites[1].src = 'https://www.truckstop77077.com/wp-content/uploads/2026/09/park-layout-sprite-3.webp'; // Load independently: a missing new asset must not stop the original traffic. policeRearImage.addEventListener('load', sizeCanvas); policeRearImage.addEventListener('error', () => { policeLink.hidden = true; }); policeRearImage.src = "https://www.truckstop77077.com/wp-content/uploads/2026/10/ts77077-police-patrol-rear-v22.png"; policeImage.addEventListener('load', sizeCanvas); policeImage.addEventListener('error', () => { stopDisabled = true; sizeCanvas(); }); policeImage.addEventListener('error', () => { policeLink.hidden = true; }); policeImage.src = "https://www.truckstop77077.com/wp-content/uploads/2026/10/ts77077-police-patrol.png"; updateControls(); })();