1/** 2 * Mobile hub pages: All Races list after simulation charts, above Search Polls. 3 */ 4(function () { 5 'use strict'; 6 var ET = window.ET = window.ET || {}; 7 8 var VISIBLE_ROWS = 10; 9 var MOBILE_SCROLL_BOTTOM_TRIM_PX = 2; 10 var _cfg = null; 11 var _resizeTimer = null; 12 13 function isHubMobileRacesViewport() { 14 if (typeof ET.isMobileLayout === 'function') return ET.isMobileLayout(); 15 return window.matchMedia('(max-width: 767px)').matches; 16 } 17 18 function detectConfig() { 19 if (document.body.classList.contains('senate-hub-page')) { 20 return { 21 kind: 'senate', 22 svgId: 'usMap', 23 mapSourceRowId: 'senateHubMapSourceRow', 24 desktopRowsId: 'allSenateRaceRows' 25 }; 26 } 27 if (document.body.classList.contains('gov-hub-page')) { 28 return { 29 kind: 'gov', 30 svgId: 'govMap', 31 mapSourceRowId: 'govHubMapSourceRow', 32 desktopRowsId: 'allSenateRaceRows' 33 }; 34 } 35 if (document.body.classList.contains('house-hub-page')) { 36 return { 37 kind: 'house', 38 svgId: 'houseCdMap', 39 mapSourceRowId: 'houseHubMapSourceRow', 40 desktopRowsId: 'allHouseRaceRows' 41 }; 42 } 43 return null; 44 } 45 46 function desktopAside() { 47 if (!_cfg) return null; 48 if (_cfg.kind === 'gov') { 49 return document.querySelector('main .gov-hub-aside.senate-races-aside'); 50 } 51 return document.querySelector('main .senate-races-aside:not(.gov-hub-aside)'); 52 } 53 54 function mobileSection() { 55 return document.getElementById('hubMobileRacesSection'); 56 } 57 58 function racesTitle() { 59 var aside = desktopAside(); 60 var h2 = aside ? aside.querySelector('.hub-aside-head-rule h2, .forecast-hub-aside-head-row h2') : null; 61 if (h2 && h2.textContent) return h2.textContent.trim(); 62 if (_cfg.kind === 'house') return 'All House Races'; 63 if (_cfg.kind === 'gov') return 'All Governor Races'; 64 return 'All Senate Races'; 65 } 66 67 function stateSortLabel() { 68 var btn = document.getElementById('sortStateBtn'); 69 if (!btn) return _cfg.kind === 'house' ? 'DISTRICT' : 'STATE'; 70 var txt = btn.textContent.replace(/[ââ]/g, '').trim(); 71 return txt || (_cfg.kind === 'house' ? 'DISTRICT' : 'STATE'); 72 } 73 74 function pollingSortLabel() { 75 var btn = document.getElementById('sortPollingBtn'); 76 if (!btn) return 'POLLING'; 77 return btn.textContent.replace(/[ââ]/g, '').trim() || 'POLLING'; 78 } 79 80 function marginSortLabel() { 81 var btn = document.getElementById('sortMarginBtn'); 82 if (!btn) return 'MARGIN'; 83 return btn.textContent.replace(/[ââ]/g, '').trim() || 'MARGIN'; 84 } 85 86 function sortArrowHtml(desktopId, mobileId) { 87 var desktop = document.getElementById(desktopId); 88 var arrow = desktop ? desktop.querySelector('.senate-sort-arrow') : null; 89 var dir = arrow ? arrow.textContent : 'â'; 90 return '<span id="' + mobileId + '" class="senate-sort-arrow">' + dir + '</span>'; 91 } 92 93 function buildSortRowHtml() { 94 return '' + 95 '<button id="hubMobileSortStateBtn" class="senate-sort-btn senate-sort-state shrink-0" type="button">' + 96 stateSortLabel() + ' ' + sortArrowHtml('sortStateBtn', 'hubMobileSortStateArrow') + 97 '</button>' + 98 '<div class="senate-races-sort-metrics">' + 99 '<div class="senate-sort-cands-col">' + 100 '<button id="hubMobileSortPollingBtn" class="senate-sort-btn senate-sort-polling" type="button">' + 101 pollingSortLabel() + ' ' + sortArrowHtml('sortPollingBtn', 'hubMobileSortPollingArrow') + 102 '</button></div>' + 103 '<button id="hubMobileSortMarginBtn" class="senate-sort-btn senate-sort-margin" type="button">' + 104 marginSortLabel() + ' ' + sortArrowHtml('sortMarginBtn', 'hubMobileSortMarginArrow') + 105 '</button></div>'; 106 } 107 108 function buildHouseFilterToolbarHtml() { 109 if (typeof ET.houseHubRacesFilterToolbarHtml === 'function') { 110 return ET.houseHubRacesFilterToolbarHtml(); 111 } 112 return ''; 113 } 114 115 function buildMapSourceToggleHtml() { 116 var isHouse = _cfg && _cfg.kind === 'house'; 117 var forecastTip = isHouse 118 ? 'Projection based on Pollsmax\'s proprietary election forecasting model.' 119 : 'Projections from our proprietary forecasting model, utilizing 100,000 simulations based on statewide and national polling data, race ratings, previous election results, and other factors.'; 120 var pollingHtml = isHouse 121 ? '<span class="forecast-hub-map-source__btn forecast-hub-map-source__btn--polling-disabled" data-source-tip="Pollsmax does not have enough polling data yet to create a polling-based projection." aria-disabled="true" tabindex="0">Polling</span>' 122 : '<button type="button" class="forecast-hub-map-source__btn" data-map-source="polling" data-source-tip="Projections based solely on our time-weighted and partisanship-adjusted polling averages.">Polling</button>'; 123 return '' + 124 '<div class="forecast-hub-map-source-head forecast-hub-map-source-row hub-mobile-races-map-source-cell" data-hub-mobile-map-source="1">' + 125 '<div class="forecast-hub-map-source race-view-toggle" role="group" aria-label="Race list data source">' + 126 '<button type="button" class="forecast-hub-map-source__btn" data-map-source="ratings" data-source-tip="' + forecastTip + '">Forecast</button>' + 127 pollingHtml + 128 '</div></div>'; 129 } 130 131 function buildSectionHtml() { 132 var houseFilter = (_cfg && _cfg.kind === 'house') ? buildHouseFilterToolbarHtml() : ''; 133 return '' + 134 '<section id="hubMobileRacesSection" class="hub-mobile-races-section senate-races-aside" aria-labelledby="hubMobileRacesHeading">' + 135 '<div class="hub-mobile-races-inner">' + 136 '<div class="hub-mobile-races-head-area forecast-hub-map-head-area">' + 137 '<div class="block border-b border-slate-800/80 pb-4 mb-3 hub-mobile-races-title-block">' + 138 '<h2 id="hubMobileRacesHeading" class="forecast-hub-map-title text-xl sm:text-2xl font-bold tracking-tight text-white" style="position:relative;top:2px;">' + 139 racesTitle() + '</h2></div>' + 140 '<div class="forecast-hub-map-head-slot hub-mobile-races-head-slot">' + 141 buildMapSourceToggleHtml() + 142 '</div></div>' +
143 '<div class="forecast-hub-aside-races-body hub-mobile-races-body">' + 144 houseFilter + 145 '<div class="mb-2 senate-races-sort-row hub-mobile-races-sort-row px-[6px]">' + buildSortRowHtml() + '</div>' + 146 '<div class="hub-mobile-races-scroll-wrap">' + 147 '<div id="hubMobileRaceRows" class="hub-mobile-races-rows"></div>' + 148 '</div>' + 149 '</div></div></section>'; 150 } 151 152 function findSearchSection() { 153 return document.querySelector('.senate-right-content .senate-search-polls-section') || 154 document.querySelector('.senate-search-polls-section'); 155 } 156 157 function findSearchInsertPoint() { 158 var search = findSearchSection(); 159 if (!search || !search.parentNode) return null; 160 return { 161 parent: search.parentNode, 162 before: search 163 }; 164 } 165 166 function mountSectionAboveSearch(section) { 167 var insert = findSearchInsertPoint(); 168 if (!insert || !section) return false; 169 if (section.parentNode !== insert.parent) { 170 insert.parent.insertBefore(section, insert.before); 171 return true; 172 } 173 if (insert.before && section.nextElementSibling !== insert.before) { 174 insert.parent.insertBefore(section, insert.before); 175 return true; 176 } 177 return true; 178 } 179 180 ET.repositionHubMobileRacesSection = function () { 181 if (!isHubMobileRacesViewport()) return; 182 var section = mobileSection(); 183 if (!section) return; 184 mountSectionAboveSearch(section); 185 }; 186 187 function ensureSection(cfg) { 188 _cfg = cfg; 189 if (!isHubMobileRacesViewport()) { 190 var existing = mobileSection(); 191 if (existing) existing.hidden = true; 192 return null; 193 } 194 var insert = findSearchInsertPoint(); 195 if (!insert || !insert.parent) return null; 196 var section = mobileSection(); 197 if (!section) { 198 var wrap = document.createElement('div'); 199 wrap.innerHTML = buildSectionHtml(); 200 section = wrap.firstElementChild; 201 if (!section) return null; 202 insert.parent.insertBefore(section, insert.before); 203 bindMobileControls(cfg); 204 } else { 205 /* Static HTML shell: bind controls once, keep reserved height. */ 206 if (!section.getAttribute('data-hub-mobile-races-bound')) { 207 section.setAttribute('data-hub-mobile-races-bound', '1'); 208 bindMobileControls(cfg); 209 } 210 mountSectionAboveSearch(section); 211 } 212 section.hidden = false; 213 return section; 214 } 215 216 function bindMobileControls(cfg) { 217 bindOnce('hub-mobile-map-source', function () { 218 var mobileRow = document.querySelector('[data-hub-mobile-map-source="1"]'); 219 if (!mobileRow || mobileRow.getAttribute('data-hub-mobile-map-bound')) return; 220 mobileRow.setAttribute('data-hub-mobile-map-bound', '1'); 221 mobileRow.querySelectorAll('[data-map-source]').forEach(function (btn) { 222 btn.addEventListener('click', function () { 223 var src = btn.getAttribute('data-map-source'); 224 var mapRow = document.getElementById(cfg.mapSourceRowId); 225 if (!mapRow) return; 226 var mapBtn = mapRow.querySelector('[data-map-source="' + src + '"]'); 227 if (mapBtn) mapBtn.click(); 228 }); 229 }); 230 if (typeof ET.initForecastHubSourceTips === 'function') { 231 ET.initForecastHubSourceTips(mobileRow); 232 } 233 }); 234 235 bindOnce('hub-mobile-sort-delegate', function () { 236 var body = document.querySelector('.hub-mobile-races-body'); 237 if (!body) return; 238 body.addEventListener('click', function (ev) { 239 var t = ev.target; 240 if (!t || !t.closest) return; 241 var btn = t.closest('#hubMobileSortStateBtn, #hubMobileSortPollingBtn, #hubMobileSortMarginBtn'); 242 if (!btn) return; 243 var mapId = btn.id === 'hubMobileSortStateBtn' ? 'sortStateBtn' 244 : btn.id === 'hubMobileSortPollingBtn' ? 'sortPollingBtn' 245 : 'sortMarginBtn'; 246 var desktop = document.getElementById(mapId); 247 if (desktop) desktop.click(); 248 requestAnimationFrame(function () { 249 ET.syncHubMobileRacesFromDesktop(cfg.svgId); 250 }); 251 }); 252 }); 253 } 254 255 function bindOnce(key, fn) { 256 var attr = 'data-hub-mobile-races-' + key; 257 if (document.body.getAttribute(attr)) return; 258 document.body.setAttribute(attr, '1'); 259 fn(); 260 } 261 262 function syncMobileMapSource(mapSource) { 263 var mobileRow = document.querySelector('[data-hub-mobile-map-source="1"]'); 264 if (!mobileRow) return; 265 mobileRow.querySelectorAll('[data-map-source]').forEach(function (btn) { 266 var active = btn.getAttribute('data-map-source') === mapSource; 267 btn.classList.toggle('forecast-hub-map-source__btn--active', active); 268 btn.setAttribute('aria-pressed', active ? 'true' : 'false'); 269 }); 270 } 271
272 ET.syncHubMobileRacesMapSource = function (rowId, mapSource) { 273 if (!isHubMobileRacesViewport() || !mobileSection()) return; 274 if (_cfg && rowId && rowId !== _cfg.mapSourceRowId) return; 275 syncMobileMapSource(mapSource); 276 }; 277 278 function syncMobileSortHeaders() { 279 var sortRow = document.querySelector('.hub-mobile-races-sort-row'); 280 if (!sortRow) return; 281 sortRow.innerHTML = buildSortRowHtml(); 282 bindMobileControls(_cfg); 283 } 284 285 function syncMobileVoteColVisibility() { 286 var section = mobileSection(); 287 if (!section || !_cfg) return; 288 var threshold = 280; 289 if (_cfg.kind === 'house') { 290 threshold = (typeof ET.houseHubAsideVoteColHideRowWidth === 'number') 291 ? ET.houseHubAsideVoteColHideRowWidth 292 : ((typeof ET.hubAsideVoteColHideRowWidth === 'number') ? ET.hubAsideVoteColHideRowWidth : 280); 293 } else if (typeof ET.hubAsideVoteColHideRowWidth === 'number') { 294 threshold = ET.hubAsideVoteColHideRowWidth; 295 } 296 var sample = section.querySelector('#hubMobileRaceRows .senate-race-row'); 297 var rowW = sample ? sample.getBoundingClientRect().width : section.getBoundingClientRect().width; 298 if (rowW > 0) { 299 section.dataset.hubAsideLastRowWidth = String(Math.round(rowW)); 300 } 301 section.classList.toggle('hub-aside-hide-vote-col', rowW > 0 && rowW < threshold); 302 if (typeof ET.syncHubAsideVoteColRowFontSize === 'function') { 303 ET.syncHubAsideVoteColRowFontSize(section); 304 } 305 if (_cfg.kind === 'house') { 306 if (typeof ET.syncHouseHubAsideRaceRowLabels === 'function') { 307 ET.syncHouseHubAsideRaceRowLabels(); 308 } 309 } else if (typeof ET.syncHubAsideStateRaceRowLabelsForAside === 'function') { 310 ET.syncHubAsideStateRaceRowLabelsForAside(section); 311 } 312 if (typeof ET.syncHubAsideProjectedVoteSortLabel === 'function') { 313 ET.syncHubAsideProjectedVoteSortLabel(_cfg.svgId); 314 } 315 syncMobileSortHeaders(); 316 } 317 318 function syncMobileScrollHeight() { 319 var wrap = document.querySelector('.hub-mobile-races-scroll-wrap'); 320 var rowsEl = document.getElementById('hubMobileRaceRows'); 321 if (!wrap || !rowsEl) return; 322 var rows = rowsEl.querySelectorAll('.senate-race-row'); 323 if (!rows.length) { 324 wrap.style.height = ''; 325 wrap.style.maxHeight = ''; 326 rowsEl.style.maxHeight = ''; 327 return; 328 } 329 var count = Math.min(VISIBLE_ROWS, rows.length); 330 var first = rows[0]; 331 var last = rows[count - 1]; 332 var r0 = first.getBoundingClientRect(); 333 var rLast = last.getBoundingClientRect(); 334 var height = Math.ceil(rLast.bottom - r0.top); 335 if (height <= 0) { 336 height = Math.ceil(first.getBoundingClientRect().height * count); 337 } 338 height = Math.max(0, height - MOBILE_SCROLL_BOTTOM_TRIM_PX); 339 wrap.style.height = height + 'px'; 340 wrap.style.maxHeight = height + 'px'; 341 rowsEl.style.maxHeight = height + 'px'; 342 wrap.classList.add('hub-mobile-races-scroll-wrap--ready'); 343 } 344 345 ET.syncHubMobileRacesFromDesktop = function (svgId) { 346 if (!isHubMobileRacesViewport()) { 347 var hidden = mobileSection(); 348 if (hidden) hidden.hidden = true; 349 return; 350 } 351 var cfg = detectConfig(); 352 if (!cfg) return; 353 if (svgId && svgId !== cfg.svgId) return; 354 var section = ensureSection(cfg); 355 if (!section) return; 356 357 /* 358 * House label sync (syncMobileVoteColVisibility -> syncHouseHubAsideRaceRowLabels) 359 * writes textContent back into the OBSERVED desktop list. Left connected, the 360 * observer re-fires this sync in a ~16ms loop: #hubMobileRaceRows is re-cloned 361 * every tick (flag <img>s reset -> flash then blank) and the constant DOM churn 362 * detaches tapped rows before their click fires (map/list/search stop navigating). 363 * Detach while we mutate, then drop the self-caused records before re-observing. 364 */ 365 var obs = _desktopRowsObs; 366 if (obs) obs.disconnect(); 367 368 var desktopRows = document.getElementById(cfg.desktopRowsId); 369 var mobileRows = document.getElementById('hubMobileRaceRows'); 370 if (desktopRows && mobileRows) { 371 mobileRows.innerHTML = desktopRows.innerHTML; 372 } 373 374 syncMobileSortHeaders(); 375 376 var ui = typeof ET.getForecastHubMapUi === 'function' ? ET.getForecastHubMapUi(cfg.svgId) : null; 377 if (ui && ui.mapSource) syncMobileMapSource(ui.mapSource); 378 379 syncMobileVoteColVisibility(); 380 381 var heading = document.getElementById('hubMobileRacesHeading'); 382 if (heading) heading.textContent = racesTitle(); 383 384 if (obs && _desktopRowsObsTarget) { 385 obs.takeRecords(); 386 obs.observe(_desktopRowsObsTarget, { childList: true, subtree: true, characterData: true }); 387 } 388 389 if (_cfg && _cfg.kind === 'house' && typeof ET.bindHouseHubRacesFilter === 'function') { 390 ET.bindHouseHubRacesFilter(); 391 } 392 393 requestAnimationFrame(function () { 394 requestAnimationFrame(function () { 395 syncMobileScrollHeight(); 396 }); 397 }); 398 }; 399 400 var _desktopRowsObs = null; 401 var _desktopRowsObsTarget = null; 402 var _desktopRowsObsRetry = 0; 403 404 ET.initHubMobileRaces = function () { 405 if (!isHubMobileRacesViewport()) return; 406 var cfg = detectConfig(); 407 if (!cfg) return; 408 ensureSection(cfg); 409 ET.syncHubMobileRacesFromDesktop(cfg.svgId); 410 bindDesktopRowsObserver(cfg); 411 }; 412 413 ET.scheduleHubMobileRacesInit = function () { 414 /* House has ~435 rows â fewer retries; MutationObserver covers late desktop fills. */ 415 var delays = (document.body && document.body.classList.contains('house-hub-page')) 416 ? [0, 100, 500] 417 : [0, 50, 200, 600, 1500, 3500]; 418 delays.forEach(function (ms) { 419 setTimeout(function () { 420 ET.initHubMobileRaces(); 421 }, ms); 422 }); 423 }; 424 425 function bindDesktopRowsObserver(cfg) { 426 if (_desktopRowsObs) return; 427 var desktopRows = document.getElementById(cfg.desktopRowsId); 428 if (!desktopRows) { 429 if (_desktopRowsObsRetry++ < 40) { 430 setTimeout(function () { bindDesktopRowsObserver(cfg); }, 100); 431 } 432 return; 433 } 434 if (!('MutationObserver' in window)) return; 435 var obsTimer; 436 _desktopRowsObs = new MutationObserver(function () { 437 if (ET._houseHubRacesFilterApplying) return; 438 clearTimeout(obsTimer); 439 obsTimer = setTimeout(function () { 440 if (ET._houseHubRacesFilterApplying) return; 441 ET.syncHubMobileRacesFromDesktop(cfg.svgId); 442 }, 16); 443 }); 444 _desktopRowsObsTarget = desktopRows; 445 _desktopRowsObs.observe(desktopRows, { childList: true, subtree: true, characterData: true }); 446 } 447 448 (function wrapAsideRefresh() { 449 var orig = ET.refreshForecastHubAsideRaces; 450 ET.refreshForecastHubAsideRaces = function (svgId) { 451 if (typeof orig === 'function') orig(svgId); 452 ET.syncHubMobileRacesFromDesktop(svgId); 453 }; 454 })(); 455 456 function scheduleResizeSync() { 457 if (document.body && document.body.classList.contains('house-hub-page') && 458 typeof ET.isHouseHubMobile === 'function' && ET.isHouseHubMobile() && 459 typeof ET.houseHubMobileWidthChanged === 'function' && !ET.houseHubMobileWidthChanged()) { 460 return; 461 } 462 clearTimeout(_resizeTimer); 463 _resizeTimer = setTimeout(function () { 464 if (!isHubMobileRacesViewport()) { 465 var section = mobileSection(); 466 if (section) section.hidden = true; 467 return; 468 } 469 ET.initHubMobileRaces(); 470 }, 80); 471 } 472 473 if (!window._hubMobileRacesResizeBound) { 474 window._hubMobileRacesResizeBound = true; 475 window.addEventListener('resize', scheduleResizeSync, { passive: true }); 476 if (typeof window.matchMedia === 'function') { 477 var mobileMq = window.matchMedia('(max-width: 767px)'); 478 if (typeof mobileMq.addEventListener === 'function') { 479 mobileMq.addEventListener('change', scheduleResizeSync); 480 } else if (typeof mobileMq.addListener === 'function') { 481 mobileMq.addListener(scheduleResizeSync); 482 } 483 } 484 } 485 486 function bootHubMobileRaces() { 487 ET.scheduleHubMobileRacesInit(); 488 } 489 490 if (document.readyState === 'loading') { 491 document.addEventListener('DOMContentLoaded', bootHubMobileRaces); 492 } else { 493 bootHubMobileRaces(); 494 } 495 window.addEventListener('load', bootHubMobileRaces); 496})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.