https://www.ziptrak.com.au/wp-content/themes/wp-theme-ziptrak/includes/js/components/globe-widget.js
1(() => { 2 'use strict'; 3 4 const DOT_OPACITY = 0.2; 5 const DEFAULT_FOCUS_LON_OFFSET = 34; 6 const DEFAULT_FOCUS_LAT_OFFSET = 34; 7 const ROTATION_DURATION = 700; 8 const ROTATION_MIN_FRAME_INTERVAL = 1000 / 30; 9 const AUTO_SCROLL_SUPPRESS_DURATION = 1400; 10 const SECTION_SCROLL_TIMEOUT = 1800; 11 const TOOLTIP_EDGE_OFFSET = 8; 12 const TOOLTIP_BOTTOM_OFFSET = 16; 13 const TOOLTIP_RIGHT_OFFSET = 24; 14 const FOCUSED_AVAILABLE_COLOR = '#BFA94F'; 15 const COUNTRY_DESCRIPTION = 'Default country description.'; 16 17 const globalConfig = window.ZiptrakGlobeConfig || {}; 18 const defaults = globalConfig.defaults || {}; 19 const worldDataUrl = globalConfig.worldDataUrl || ''; 20 const worldAdmin1DataUrl = globalConfig.worldAdmin1DataUrl || ''; 21 const countryMetaUrl = globalConfig.countryMetaUrl || ''; 22 const fallbackCountriesUrl = globalConfig.fallbackCountriesUrl || ''; 23 let cachedDataPromise = null; 24 25 const normalizeToken = (value) => String(value || '') 26 .toUpperCase() 27 .replace(/[^A-Z0-9]/g, ''); 28 29 const normalizeStateToken = (value) => { 30 const input = String(value || ''); 31 const ascii = typeof input.normalize === 'function' 32 ? input.normalize('NFD').replace(/[\u0300-\u036f]/g, '') 33 : input; 34 return normalizeToken(ascii); 35 }; 36 37 const normalizeIsoN3 = (value) => { 38 const raw = String(value || '').replace(/[^0-9]/g, ''); 39 if (!raw) { 40 return ''; 41 } 42 43 return `000${raw}`.slice(-3); 44 }; 45 46 const parseCountryTokens = (value) => { 47 if (Array.isArray(value)) { 48 return value 49 .map((item) => String(item || '').trim()) 50 .filter((item) => item !== ''); 51 } 52 53 if (typeof value === 'string') { 54 return value 55 .split(',') 56 .map((item) => item.trim()) 57 .filter((item) => item !== ''); 58 } 59 60 return []; 61 }; 62 63 const buildFallbackCountryMap = (items) => { 64 const map = {}; 65 66 (items || []).forEach((item) => { 67 const iso2 = String(item.iso2 || item.iso_a2 || '').toUpperCase(); 68 const iso3 = String(item.iso3 || item.iso_a3 || '').toUpperCase(); 69 const id = normalizeIsoN3(item.isoN3 || item.iso_n3 || item.id || ''); 70 const name = String(item.name_long || item.name || iso2 || iso3 || id || 'Unknown'); 71 const centroid = Array.isArray(item.centroid) && item.centroid.length === 2 72 ? [Number(item.centroid[0]), Number(item.centroid[1])] 73 : null; 74 75 if (!centroid || !isFinite(centroid[0]) || !isFinite(centroid[1])) { 76 return; 77 } 78 79 const info = { 80 id, 81 iso2, 82 iso3, 83 name, 84 centroid, 85 nameNorm: normalizeToken(name), 86 idNorm: normalizeToken(id), 87 iso2Norm: normalizeToken(iso2), 88 iso3Norm: normalizeToken(iso3), 89 isVirtual: true 90 }; 91 92 [info.nameNorm, info.idNorm, info.iso2Norm, info.iso3Norm].forEach((token) => { 93 if (token) { 94 map[token] = info; 95 } 96 }); 97 }); 98 99 return map; 100 }; 101 102 const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); 103 const escapeHtml = (value) => String(value || '') 104 .replace(/&/g, '&') 105 .replace(/</g, '<') 106 .replace(/>/g, '>') 107 .replace(/"/g, '"') 108 .replace(/'/g, '''); 109 110 const mergeConfig = (config) => { 111 const clickableCountriesValue = config.clickableCountries != null 112 ? config.clickableCountries 113 : defaults.clickableCountries; 114 115 return { 116 height: Number(config.height || 520), 117 rotationSpeed: Number(config.rotationSpeed != null ? config.rotationSpeed : defaults.rotationSpeed || 0.2), 118 globeOpacity: clamp(Number(config.globeOpacity != null ? config.globeOpacity : defaults.globeOpacity != null ? defaults.globeOpacity : 1), 0, 1), 119 backsideDotOpacity: clamp(Number(config.backsideDotOpacity != null ? config.backsideDotOpacity : defaults.backsideDotOpacity != null ? defaults.backsideDotOpacity : DOT_OPACITY), 0, 1), 120 focusLonOffset: clamp(Number(config.focusLonOffset != null ? config.focusLonOffset : defaults.focusLonOffset != null ? defaults.focusLonOffset : DEFAULT_FOCUS_LON_OFFSET), -89, 89), 121 focusLatOffset: clamp(Number(config.focusLatOffset != null ? config.focusLatOffset : defaults.focusLatOffset != null ? defaults.focusLatOffset : DEFAULT_FOCUS_LAT_OFFSET), -89, 89), 122 oceanColor: config.oceanColor || defaults.oceanColor || '#0B2F54', 123 landColor: config.landColor || defaults.landColor || '#6D9F74', 124 borderColor: config.borderColor || defaults.borderColor || '#9BB8D1', 125 clickableColor: config.clickableColor || defaults.clickableColor || '#FFC03C', 126 clickableHoverColor: config.clickableHoverColor || defaults.clickableHoverColor || '#FFE27A', 127 focusedAvailableColor: config.focusedAvailableColor || defaults.focusedAvailableColor || FOCUSED_AVAILABLE_COLOR,
128 coreCountry: String(config.coreCountry != null ? config.coreCountry : defaults.coreCountry || '').trim(), 129 clickableCountries: parseCountryTokens(clickableCountriesValue), 130 countryLinks: config.countryLinks && typeof config.countryLinks === 'object' ? config.countryLinks : {}, 131 countryProfiles: config.countryProfiles && typeof config.countryProfiles === 'object' 132 ? config.countryProfiles 133 : (defaults.countryProfiles && typeof defaults.countryProfiles === 'object' ? defaults.countryProfiles : {}) 134 }; 135 }; 136 137 const buildLinkMap = (links) => { 138 const map = {}; 139 Object.keys(links || {}).forEach((key) => { 140 const normalized = normalizeToken(key); 141 const href = String(links[key] || '').trim(); 142 if (normalized && href) { 143 map[normalized] = href; 144 } 145 }); 146 147 return map; 148 }; 149 150 const getWorldData = () => { 151 if (cachedDataPromise) { 152 return cachedDataPromise; 153 } 154 155 const fallbackCountriesPromise = fallbackCountriesUrl 156 ? d3.json(fallbackCountriesUrl).catch(() => []) 157 : Promise.resolve([]); 158 159 cachedDataPromise = Promise.all([ 160 d3.json(worldDataUrl), 161 d3.json(worldAdmin1DataUrl).catch(() => null), 162 d3.tsv(countryMetaUrl).catch(() => []), 163 fallbackCountriesPromise 164 ]).then(([worldData, admin1Data, rows, fallbackPayload]) => { 165 const countryMetaById = {}; 166 (rows || []).forEach((row) => { 167 const key = normalizeIsoN3(row.iso_n3); 168 if (key) { 169 countryMetaById[key] = row; 170 } 171 }); 172 173 const admin1Features = admin1Data && Array.isArray(admin1Data.features) 174 ? admin1Data.features 175 : []; 176 const fallbackRows = Array.isArray(fallbackPayload) 177 ? fallbackPayload 178 : (fallbackPayload && Array.isArray(fallbackPayload.countries) ? fallbackPayload.countries : []); 179 const fallbackCountryByToken = buildFallbackCountryMap(fallbackRows); 180 const primaryPolygonOnlyRows = fallbackPayload && Array.isArray(fallbackPayload.primaryPolygonOnlyCountries) 181 ? fallbackPayload.primaryPolygonOnlyCountries 182 : []; 183 const primaryPolygonOnlyTokenSet = {}; 184 primaryPolygonOnlyRows.forEach((value) => { 185 const token = normalizeToken(value); 186 if (token) { 187 primaryPolygonOnlyTokenSet[token] = true; 188 } 189 }); 190 191 return { 192 worldData, 193 admin1Features, 194 countryMetaById, 195 fallbackCountryByToken, 196 primaryPolygonOnlyTokenSet 197 }; 198 }); 199 200 return cachedDataPromise; 201 }; 202 203 const getCountryInfo = (feature, countryMetaById) => { 204 const id = normalizeIsoN3(feature && feature.id); 205 const meta = countryMetaById[id] || {}; 206 const name = meta.name_long || meta.name || id; 207 const iso2 = String(meta.iso_a2 || '').toUpperCase(); 208 const iso3 = String(meta.iso_a3 || '').toUpperCase(); 209 210 return { 211 id, 212 iso2, 213 iso3, 214 name, 215 nameNorm: normalizeToken(name), 216 idNorm: normalizeToken(id), 217 iso2Norm: normalizeToken(iso2), 218 iso3Norm: normalizeToken(iso3) 219 }; 220 }; 221 222 const createPulse = (selection) => { 223 selection 224 .attr('r', 3) 225 .attr('fill', '#fff') 226 .attr('fill-opacity', 0.95) 227 .attr('stroke', '#fff') 228 .attr('stroke-width', 1) 229 .attr('stroke-opacity', 0.8) 230 .transition() 231 .duration(1400) 232 .ease(d3.easeCubicOut) 233 .attr('r', 9) 234 .attr('stroke-opacity', 0) 235 .attr('fill-opacity', 0) 236 .on('end', function pulseEnd() { 237 createPulse(d3.select(this)); 238 }); 239 }; 240 241 const isFrontHemisphere = (projection, lonLat) => { 242 const rotate = projection.rotate(); 243 const center = [-rotate[0], -rotate[1]]; 244 return d3.geoDistance(center, lonLat) <= (Math.PI / 2); 245 }; 246 247 const getFocusRotateForCentroid = (centroid, lonOffset, latOffset) => { 248 const lon = centroid[0]; 249 const lat = centroid[1]; 250 return [ 251 -lon + lonOffset, 252 clamp(-(lat + latOffset), -89, 89), 253 0 254 ]; 255 }; 256 257 const setupWidget = ( 258 widget, 259 worldData, 260 admin1Features, 261 countryMetaById, 262 fallbackCountryByToken = {}, 263 primaryPolygonOnlyTokenSet = {} 264 ) => { 265 if (typeof window.d3 === 'undefined' || typeof window.topojson === 'undefined') { 266 return; 267 } 268 269 const isMobileOrTabletViewport = () => window.matchMedia('(max-width: 991px)').matches; 270 const rawConfig = JSON.parse(widget.getAttribute('data-globe-config') || '{}') || {}; 271 const config = mergeConfig(rawConfig); 272 const backsideDotOpacity = config.backsideDotOpacity; 273 const configuredHeight = config.height > 0 ? config.height : 520; 274 275 const getViewportSize = () => { 276 const parentRect = widget.parentElement 277 ? widget.parentElement.getBoundingClientRect() 278 : null; 279 const parentHeight = parentRect ? Math.round(parentRect.height) : 0; 280 const parentWidth = parentRect ? Math.round(parentRect.width) : 0; 281 const mobileOrTablet = isMobileOrTabletViewport(); 282 const viewportWidth = Math.round(window.innerWidth || document.documentElement.clientWidth || 0); 283 let nextWidth = mobileOrTablet 284 ? Math.max(280, parentWidth || widget.clientWidth || 800) 285 : Math.max(280, widget.clientWidth || 800); 286 let nextHeight = (mobileOrTablet && parentHeight > 0) 287 ? parentHeight 288 : configuredHeight; 289 290 if (mobileOrTablet) { 291 nextWidth = Math.max(280, viewportWidth || nextWidth); 292 nextHeight = nextWidth; 293 } 294 295 return { width: nextWidth, height: nextHeight }; 296 }; 297 298 let width = 0; 299 let height = 0; 300 const applyWidgetSize = () => { 301 const size = getViewportSize(); 302 width = size.width; 303 height = size.height; 304 widget.style.height = `${height}px`; 305 return size; 306 }; 307 308 applyWidgetSize(); 309 widget.style.opacity = `${config.globeOpacity}`; 310 311 const clickableSet = {};
312 config.clickableCountries.forEach((item) => { 313 const token = normalizeToken(item); 314 if (token) { 315 clickableSet[token] = true; 316 } 317 }); 318 319 const coreCountryToken = normalizeToken(config.coreCountry); 320 const linksByKey = buildLinkMap(config.countryLinks); 321 const profileByKey = {}; 322 Object.keys(config.countryProfiles || {}).forEach((isoCode) => { 323 const key = normalizeToken(isoCode); 324 if (key) { 325 profileByKey[key] = config.countryProfiles[isoCode]; 326 } 327 }); 328 const countries = topojson.feature(worldData, worldData.objects.countries).features; 329 const borders = topojson.mesh(worldData, worldData.objects.countries, (a, b) => a !== b); 330 331 const getCountryProfile = (info) => ( 332 profileByKey[info.iso2Norm] || 333 profileByKey[info.iso3Norm] || 334 profileByKey[info.idNorm] || 335 profileByKey[info.nameNorm] || 336 null 337 ); 338 339 const getStateTokenFromProfile = (info) => { 340 const profile = getCountryProfile(info); 341 if (!profile || !profile.state) { 342 return ''; 343 } 344 return normalizeStateToken(profile.state); 345 }; 346 347 const getAdmin1CountryTokens = (properties) => { 348 const tokens = {}; 349 [ 350 properties && properties.iso_a2, 351 properties && properties.adm0_a3, 352 properties && properties.adm0_name, 353 properties && properties.geonunit, 354 properties && properties.admin 355 ].forEach((value) => { 356 const token = normalizeToken(value); 357 if (token) { 358 tokens[token] = true; 359 } 360 }); 361 return tokens; 362 }; 363 364 const getAdmin1StateTokens = (properties) => { 365 const tokens = {}; 366 [ 367 properties && properties.name, 368 properties && properties.name_en, 369 properties && properties.name_alt, 370 properties && properties.postal, 371 properties && properties.iso_3166_2, 372 properties && properties.code_hasc 373 ].forEach((value) => { 374 const stringValue = String(value || '').trim(); 375 if (!stringValue) { 376 return; 377 } 378 379 stringValue.split('|').forEach((part) => { 380 const normalized = normalizeStateToken(part); 381 if (normalized) { 382 tokens[normalized] = true; 383 } 384 385 const dashIndex = part.indexOf('-'); 386 if (dashIndex > 0) { 387 const suffix = normalizeStateToken(part.slice(dashIndex + 1)); 388 if (suffix) { 389 tokens[suffix] = true; 390 } 391 } 392 393 const dotIndex = part.indexOf('.'); 394 if (dotIndex > 0) { 395 const suffix = normalizeStateToken(part.slice(dotIndex + 1)); 396 if (suffix) { 397 tokens[suffix] = true; 398 } 399 } 400 }); 401 }); 402 return tokens; 403 }; 404 405 const getCountryLink = (info) => { 406 const profile = getCountryProfile(info); 407 return ( 408 (profile && profile.pageLink) || 409 linksByKey[info.nameNorm] || 410 linksByKey[info.iso2Norm] || 411 linksByKey[info.iso3Norm] || 412 linksByKey[info.idNorm] || 413 '' 414 ); 415 }; 416 417 const isCoreCountry = (info) => Boolean( 418 coreCountryToken && 419 ( 420 coreCountryToken === info.nameNorm || 421 coreCountryToken === info.iso2Norm || 422 coreCountryToken === info.iso3Norm || 423 coreCountryToken === info.idNorm 424 ) 425 ); 426 427 const isClickableCountry = (info) => Boolean( 428 clickableSet[info.nameNorm] || 429 clickableSet[info.iso2Norm] || 430 clickableSet[info.iso3Norm] || 431 clickableSet[info.idNorm] || 432 Boolean(getCountryProfile(info)) || 433 isCoreCountry(info) || 434 Boolean(getCountryLink(info)) 435 ); 436 437 const isPrimaryPolygonOnlyCountry = (info) => Boolean( 438 primaryPolygonOnlyTokenSet[info.nameNorm] || 439 primaryPolygonOnlyTokenSet[info.iso2Norm] || 440 primaryPolygonOnlyTokenSet[info.iso3Norm] || 441 primaryPolygonOnlyTokenSet[info.idNorm] 442 ); 443 444 const countryInfoById = {}; 445 const countryByToken = {}; 446 countries.forEach((country) => { 447 const info = getCountryInfo(country, countryMetaById); 448 countryInfoById[country.id] = info; 449 [info.nameNorm, info.iso2Norm, info.iso3Norm, info.idNorm].forEach((token) => { 450 if (token && !countryByToken[token]) { 451 countryByToken[token] = country; 452 } 453 }); 454 }); 455 456 const virtualCountryByFocusId = {}; 457 const virtualCountryByToken = {}; 458 const addVirtualCountryFromToken = (token) => { 459 const normalized = normalizeToken(token); 460 if (!normalized || countryByToken[normalized]) { 461 return; 462 } 463 464 const fallbackInfo = fallbackCountryByToken[normalized]; 465 if (!fallbackInfo || !Array.isArray(fallbackInfo.centroid)) { 466 return; 467 } 468 469 const focusId = `virtual-${fallbackInfo.idNorm || fallbackInfo.iso2Norm || fallbackInfo.nameNorm || normalized}`; 470 if (!virtualCountryByFocusId[focusId]) { 471 virtualCountryByFocusId[focusId] = Object.assign({}, fallbackInfo, { focusId }); 472 } 473 474 const info = virtualCountryByFocusId[focusId]; 475 [info.nameNorm, info.iso2Norm, info.iso3Norm, info.idNorm].forEach((value) => { 476 if (value) { 477 virtualCountryByToken[value] = info; 478 } 479 }); 480 }; 481 482 Object.keys(clickableSet).forEach(addVirtualCountryFromToken);
483 Object.keys(profileByKey).forEach(addVirtualCountryFromToken); 484 Object.keys(linksByKey).forEach(addVirtualCountryFromToken); 485 if (coreCountryToken) { 486 addVirtualCountryFromToken(coreCountryToken); 487 } 488 489 const virtualCountries = Object.keys(virtualCountryByFocusId) 490 .map((key) => virtualCountryByFocusId[key]) 491 .filter((info) => isClickableCountry(info) || isCoreCountry(info)); 492 493 const findCountryTargetByToken = (token) => { 494 const normalized = normalizeToken(token); 495 if (!normalized) { 496 return null; 497 } 498 499 const country = countryByToken[normalized]; 500 if (country) { 501 return { 502 type: 'country', 503 focusId: country.id, 504 country, 505 info: countryInfoById[country.id] 506 }; 507 } 508 509 const virtualInfo = virtualCountryByToken[normalized]; 510 if (virtualInfo) { 511 return { 512 type: 'virtual', 513 focusId: virtualInfo.focusId, 514 country: null, 515 info: virtualInfo 516 }; 517 } 518 519 return null; 520 }; 521 522 let primaryPolygonFeatureByCountryId = {}; 523 const getTargetCentroid = (target) => { 524 if (!target) { 525 return null; 526 } 527 528 if (target.type === 'virtual') { 529 const centroid = target.info && Array.isArray(target.info.centroid) ? target.info.centroid : null; 530 if (centroid && isFinite(centroid[0]) && isFinite(centroid[1])) { 531 return centroid; 532 } 533 return null; 534 } 535 536 if (!target.country) { 537 return null; 538 } 539 540 const primaryPolygon = primaryPolygonFeatureByCountryId[target.country.id]; 541 const centroid = d3.geoCentroid(primaryPolygon || target.country); 542 if (!centroid || !isFinite(centroid[0]) || !isFinite(centroid[1])) { 543 return null; 544 } 545 return centroid; 546 }; 547 548 const stateFeatureByCountryId = {}; 549 countries.forEach((country) => { 550 const info = countryInfoById[country.id]; 551 const stateToken = getStateTokenFromProfile(info); 552 if (!stateToken) { 553 return; 554 } 555 556 const countryTokens = {}; 557 [countryInfoById[country.id].iso2Norm, countryInfoById[country.id].iso3Norm, countryInfoById[country.id].nameNorm, countryInfoById[country.id].idNorm] 558 .forEach((token) => { 559 if (token) { 560 countryTokens[token] = true; 561 } 562 }); 563 564 for (let i = 0; i < admin1Features.length; i += 1) { 565 const feature = admin1Features[i]; 566 const props = feature && feature.properties ? feature.properties : {}; 567 const adminCountryTokens = getAdmin1CountryTokens(props); 568 const hasCountryMatch = Object.keys(countryTokens).some((token) => adminCountryTokens[token]); 569 if (!hasCountryMatch) { 570 continue; 571 } 572 573 const adminStateTokens = getAdmin1StateTokens(props); 574 if (adminStateTokens[stateToken]) { 575 stateFeatureByCountryId[country.id] = feature; 576 break; 577 } 578 } 579 }); 580 581 const highlightedCountries = countries.filter((country) => isClickableCountry(countryInfoById[country.id])); 582 const highlightedVirtualCountries = virtualCountries.filter((info) => isClickableCountry(info)); 583 primaryPolygonFeatureByCountryId = {}; 584 highlightedCountries.forEach((country) => { 585 const info = countryInfoById[country.id]; 586 if (!isPrimaryPolygonOnlyCountry(info)) { 587 return; 588 } 589 590 const geometry = country && country.geometry ? country.geometry : null; 591 if (!geometry || geometry.type !== 'MultiPolygon' || !Array.isArray(geometry.coordinates)) { 592 return; 593 } 594 595 let largestPolygon = null; 596 let largestArea = 0; 597 geometry.coordinates.forEach((polygonCoords) => { 598 const polygonFeature = { 599 type: 'Feature', 600 id: country.id, 601 properties: country.properties || {}, 602 geometry: { 603 type: 'Polygon', 604 coordinates: polygonCoords 605 } 606 }; 607 const area = Math.abs(d3.geoArea(polygonFeature)); 608 if (area > largestArea) { 609 largestArea = area; 610 largestPolygon = polygonFeature; 611 } 612 }); 613 614 if (largestPolygon) { 615 primaryPolygonFeatureByCountryId[country.id] = largestPolygon; 616 } 617 }); 618 const primaryClickableCountries = highlightedCountries 619 .map((country) => { 620 // Keep admin1 (state/province) rendering authoritative when it is available. 621 if (stateFeatureByCountryId[country.id]) { 622 return null; 623 } 624 625 const feature = primaryPolygonFeatureByCountryId[country.id]; 626 if (!feature) { 627 return null; 628 } 629 const centroid = d3.geoCentroid(feature); 630 if (!centroid || !isFinite(centroid[0]) || !isFinite(centroid[1])) { 631 return null; 632 } 633 return { 634 country, 635 info: countryInfoById[country.id], 636 feature, 637 centroid 638 }; 639 }) 640 .filter((item) => item !== null); 641 642 const projection = d3.geoOrthographic() 643 .scale(Math.min(width, height) * 0.47) 644 .translate([width / 2, height / 2]) 645 .rotate([0, -15]); 646 647 if (config.coreCountry) { 648 const coreCountryTarget = findCountryTargetByToken(config.coreCountry); 649 const centroid = getTargetCentroid(coreCountryTarget); 650 if (centroid) { 651 projection.rotate([-centroid[0], -centroid[1]]); 652 } 653 } 654 655 const path = d3.geoPath().projection(projection); 656 const dragSensitivity = 60; 657 let hoveredCountryId = null; 658 let rotationTimer = null; 659 let rotationAnimationFrame = null; 660 let focusedCountryId = null; 661 let focusedCentroid = null; 662 let preFocusRotation = null; 663 let isFocusTooltipVisible = false;
664 let suppressCloseOnScrollUntil = 0; 665 let focusRequestId = 0; 666 let isWidgetVisible = true; 667 668 const sectionElement = widget.closest('section[style*="background-color"]') 669 || widget.closest('section') 670 || widget.parentElement 671 || widget; 672 const sectionComputedStyle = window.getComputedStyle(sectionElement); 673 if (sectionComputedStyle.position === 'static') { 674 sectionElement.style.position = 'relative'; 675 } 676 677 const suppressCloseOnScroll = (durationMs) => { 678 const duration = Number(durationMs) || 0; 679 suppressCloseOnScrollUntil = Date.now() + Math.max(duration, 0); 680 }; 681 682 const isScrollCloseSuppressed = () => Date.now() < suppressCloseOnScrollUntil; 683 684 const isCurrentFocusCountry = (countryId) => ( 685 focusedCountryId !== null && 686 countryId != null && 687 String(focusedCountryId) === String(countryId) 688 ); 689 690 const shouldApplyFocusedAvailableColor = () => ( 691 focusedCountryId !== null && isFocusTooltipVisible 692 ); 693 694 const isSectionRealignmentNeeded = () => { 695 const viewportHeight = Math.round(window.innerHeight || document.documentElement.clientHeight || 0); 696 if (!viewportHeight) { 697 return true; 698 } 699 700 const rect = sectionElement.getBoundingClientRect(); 701 const intersectsViewport = rect.bottom > 0 && rect.top < viewportHeight; 702 if (!intersectsViewport) { 703 return true; 704 } 705 706 if (rect.height > viewportHeight) { 707 return Math.abs(rect.bottom - viewportHeight) > 8; 708 } 709 710 return Math.abs(rect.bottom - viewportHeight) > 8; 711 }; 712 713 const scrollSectionIntoFocus = (requestId) => new Promise((resolve) => { 714 if (!isMobileOrTabletViewport()) { 715 resolve(); 716 return; 717 } 718 719 if (!isSectionRealignmentNeeded()) { 720 resolve(); 721 return; 722 } 723 724 const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 725 suppressCloseOnScroll(SECTION_SCROLL_TIMEOUT + ROTATION_DURATION); 726 sectionElement.scrollIntoView({ 727 behavior: reducedMotion ? 'auto' : 'smooth', 728 block: 'end', 729 inline: 'nearest' 730 }); 731 732 if (reducedMotion) { 733 window.requestAnimationFrame(resolve); 734 return; 735 } 736 737 const startedAt = Date.now(); 738 let stableFrameCount = 0; 739 const waitForAlignment = () => { 740 if (requestId !== focusRequestId) { 741 resolve(); 742 return; 743 } 744 745 const rect = sectionElement.getBoundingClientRect(); 746 const viewportHeight = Math.round(window.innerHeight || document.documentElement.clientHeight || 0); 747 const aligned = viewportHeight > 0 748 ? Math.abs(rect.bottom - viewportHeight) <= 4 749 : true; 750 if (aligned) { 751 stableFrameCount += 1; 752 } else { 753 stableFrameCount = 0; 754 } 755 756 if (stableFrameCount >= 3 || (Date.now() - startedAt) >= SECTION_SCROLL_TIMEOUT) { 757 resolve(); 758 return; 759 } 760 761 window.requestAnimationFrame(waitForAlignment); 762 }; 763 764 window.requestAnimationFrame(waitForAlignment); 765 }); 766 767 widget.innerHTML = '<div class="ziptrak-globe-widget__tooltip-line" hidden></div>'; 768 const tooltipLine = widget.querySelector('.ziptrak-globe-widget__tooltip-line'); 769 const syncedTooltip = document.createElement('div'); 770 syncedTooltip.className = 'ziptrak-globe-widget__tooltip'; 771 syncedTooltip.hidden = true; 772 syncedTooltip.style.position = 'absolute'; 773 syncedTooltip.style.zIndex = '9'; 774 sectionElement.appendChild(syncedTooltip); 775 776 const svg = d3.select(widget) 777 .append('svg') 778 .attr('class', 'ziptrak-globe-widget__svg') 779 .attr('width', width) 780 .attr('height', height) 781 .attr('viewBox', `0 0 ${width} ${height}`); 782 783 svg.append('path') 784 .datum({ type: 'Sphere' }) 785 .attr('class', 'ziptrak-globe-widget__sphere') 786 .attr('d', path) 787 .attr('fill', config.oceanColor); 788 789 const countriesGroup = svg.append('g').attr('class', 'ziptrak-globe-widget__countries'); 790 const bordersPath = svg.append('path') 791 .datum(borders) 792 .attr('class', 'ziptrak-globe-widget__borders') 793 .attr('fill', 'none') 794 .attr('stroke', config.borderColor) 795 .attr('stroke-width', 0.8) 796 .attr('d', path); 797 798 const updateTooltipPosition = () => { 799 if (!syncedTooltip || syncedTooltip.hidden) { 800 return; 801 } 802 803 const sectionRect = sectionElement.getBoundingClientRect(); 804 const widgetRect = widget.getBoundingClientRect(); 805 const tooltipRect = syncedTooltip.getBoundingClientRect(); 806 const viewportWidth = Math.round(window.innerWidth || document.documentElement.clientWidth || 0);
807 const viewportHeight = Math.round(window.innerHeight || document.documentElement.clientHeight || 0); 808 const maxLeft = Math.max(TOOLTIP_EDGE_OFFSET, sectionRect.width - tooltipRect.width - TOOLTIP_EDGE_OFFSET); 809 let top = TOOLTIP_EDGE_OFFSET; 810 let left = TOOLTIP_EDGE_OFFSET; 811 812 if (isMobileOrTabletViewport()) { 813 syncedTooltip.classList.add('ziptrak-globe-widget__tooltip--mobile'); 814 const targetTopInViewport = Math.max( 815 TOOLTIP_EDGE_OFFSET, 816 viewportHeight - tooltipRect.height - TOOLTIP_BOTTOM_OFFSET 817 ); 818 const targetLeftInViewport = ((viewportWidth - tooltipRect.width) / 2); 819 top = targetTopInViewport - sectionRect.top; 820 left = targetLeftInViewport - sectionRect.left; 821 } else { 822 syncedTooltip.classList.remove('ziptrak-globe-widget__tooltip--mobile'); 823 const targetTopInViewport = widgetRect.top + ((widgetRect.height - tooltipRect.height) / 2); 824 const targetLeftInViewport = widgetRect.right - tooltipRect.width - TOOLTIP_RIGHT_OFFSET; 825 top = targetTopInViewport - sectionRect.top; 826 left = targetLeftInViewport - sectionRect.left; 827 } 828 829 syncedTooltip.style.top = `${Math.max(TOOLTIP_EDGE_OFFSET, top)}px`; 830 syncedTooltip.style.left = `${clamp(left, TOOLTIP_EDGE_OFFSET, maxLeft)}px`; 831 syncedTooltip.style.transform = 'none'; 832 syncedTooltip.style.bottom = 'auto'; 833 syncedTooltip.style.right = 'auto'; 834 }; 835 836 const stopRotationAnimation = () => { 837 if (rotationAnimationFrame) { 838 window.cancelAnimationFrame(rotationAnimationFrame); 839 rotationAnimationFrame = null; 840 } 841 }; 842 843 const updateTooltipLinePosition = () => { 844 if (!syncedTooltip || !tooltipLine || !focusedCentroid || focusedCountryId === null || !isFocusTooltipVisible || syncedTooltip.hidden) { 845 return; 846 } 847 848 if (isMobileOrTabletViewport()) { 849 tooltipLine.hidden = true; 850 return; 851 } 852 853 const point = projection(focusedCentroid); 854 if (!point) { 855 tooltipLine.hidden = true; 856 return; 857 } 858 859 const widgetRect = widget.getBoundingClientRect(); 860 const tooltipRect = syncedTooltip.getBoundingClientRect(); 861 const x1 = point[0]; 862 const y1 = point[1]; 863 const x2 = (tooltipRect.left - widgetRect.left) + (tooltipRect.width / 2); 864 const y2 = (tooltipRect.top - widgetRect.top) + tooltipRect.height; 865 const dx = x2 - x1; 866 const dy = y2 - y1; 867 const length = Math.max(Math.sqrt((dx * dx) + (dy * dy)), 2); 868 const angle = Math.atan2(dy, dx); 869 870 tooltipLine.hidden = false; 871 tooltipLine.style.left = `${x1}px`; 872 tooltipLine.style.top = `${y1}px`; 873 tooltipLine.style.width = `${length}px`; 874 tooltipLine.style.transform = `rotate(${angle}rad)`; 875 }; 876 877 const hideFocusTooltip = () => { 878 isFocusTooltipVisible = false; 879 if (syncedTooltip) { 880 syncedTooltip.hidden = true; 881 } 882 if (tooltipLine) { 883 tooltipLine.hidden = true; 884 } 885 }; 886 887 const showFocusTooltip = (info) => { 888 if (!syncedTooltip) { 889 return; 890 } 891 892 const profile = getCountryProfile(info); 893 const state = profile && profile.state ? String(profile.state) : ''; 894 const description = profile && profile.description ? String(profile.description) : COUNTRY_DESCRIPTION; 895 const title = escapeHtml(info.name); 896 const stateHtml = state ? `<p class=\"ziptrak-globe-widget__tooltip-state\">${escapeHtml(state)}</p>` : ''; 897 const text = escapeHtml(description).replace(/\n/g, '<br>'); 898 syncedTooltip.innerHTML = `<button type=\"button\" class=\"ziptrak-globe-widget__tooltip-close\" aria-label=\"Close\">×</button><h4 class=\"ziptrak-globe-widget__tooltip-title\">${title}</h4>${stateHtml}<p class=\"ziptrak-globe-widget__tooltip-description\">${text}</p>`; 899 syncedTooltip.hidden = false; 900 isFocusTooltipVisible = true; 901 refreshInteractiveFills(); 902 suppressCloseOnScroll(AUTO_SCROLL_SUPPRESS_DURATION); 903 updateTooltipPosition(); 904 905 const closeButton = syncedTooltip.querySelector('.ziptrak-globe-widget__tooltip-close'); 906 if (closeButton) { 907 closeButton.addEventListener('click', (event) => { 908 event.preventDefault(); 909 event.stopPropagation(); 910 closeFocusCountry(); 911 }); 912 } 913 914 updateTooltipLinePosition(); 915 window.requestAnimationFrame(() => { 916 if (!syncedTooltip.hidden) { 917 updateTooltipPosition(); 918 updateTooltipLinePosition(); 919 } 920 }); 921 }; 922 923 const fillForCountry = (country) => { 924 const info = countryInfoById[country.id]; 925 if (stateFeatureByCountryId[country.id]) { 926 return config.landColor; 927 } 928 if (primaryPolygonFeatureByCountryId[country.id]) { 929 return config.landColor; 930 } 931 if (isClickableCountry(info) && shouldApplyFocusedAvailableColor()) { 932 return isCurrentFocusCountry(country.id) ? config.clickableColor : config.focusedAvailableColor; 933 } 934 if (hoveredCountryId === country.id && isClickableCountry(info)) { 935 return config.clickableHoverColor; 936 } 937 if (isClickableCountry(info)) { 938 return config.clickableColor; 939 } 940 return config.landColor; 941 }; 942 943 const highlightedStates = highlightedCountries 944 .map((country) => { 945 const stateFeature = stateFeatureByCountryId[country.id]; 946 if (!stateFeature) { 947 return null; 948 } 949 const centroid = d3.geoCentroid(stateFeature); 950 if (!centroid || !isFinite(centroid[0]) || !isFinite(centroid[1])) { 951 return null; 952 } 953 return { 954 country, 955 info: countryInfoById[country.id], 956 focusId: country.id, 957 feature: stateFeature, 958 centroid 959 }; 960 }) 961 .filter((item) => item !== null); 962 const highlightedStateCentroidByCountryId = {};
963 highlightedStates.forEach((item) => { 964 highlightedStateCentroidByCountryId[item.country.id] = item.centroid; 965 }); 966 967 const fillForState = (datum) => { 968 if (shouldApplyFocusedAvailableColor()) { 969 return isCurrentFocusCountry(datum.country.id) ? config.clickableColor : config.focusedAvailableColor; 970 } 971 972 return hoveredCountryId === datum.country.id ? config.clickableHoverColor : config.clickableColor; 973 }; 974 const fillForPrimaryCountry = (datum) => { 975 if (shouldApplyFocusedAvailableColor()) { 976 return isCurrentFocusCountry(datum.country.id) ? config.clickableColor : config.focusedAvailableColor; 977 } 978 979 return hoveredCountryId === datum.country.id ? config.clickableHoverColor : config.clickableColor; 980 }; 981 982 let countryPaths = null; 983 let statePaths = null; 984 let primaryCountryPaths = null; 985 986 const refreshInteractiveFills = () => { 987 if (countryPaths) { 988 countryPaths.attr('fill', (item) => fillForCountry(item)); 989 } 990 if (statePaths) { 991 statePaths.attr('fill', (item) => fillForState(item)); 992 } 993 if (primaryCountryPaths) { 994 primaryCountryPaths.attr('fill', (item) => fillForPrimaryCountry(item)); 995 } 996 }; 997 998 const clearHoverState = () => { 999 if (focusedCountryId !== null) { 1000 return; 1001 } 1002 hoveredCountryId = null; 1003 refreshInteractiveFills(); 1004 svg.style('cursor', 'default'); 1005 hideFocusTooltip(); 1006 }; 1007 1008 const setHoverState = (countryId, cursor = 'pointer') => { 1009 if (focusedCountryId !== null) { 1010 return; 1011 } 1012 hoveredCountryId = countryId; 1013 refreshInteractiveFills(); 1014 svg.style('cursor', cursor); 1015 }; 1016 1017 countryPaths = countriesGroup 1018 .selectAll('path') 1019 .data(countries) 1020 .enter() 1021 .append('path') 1022 .attr('class', 'ziptrak-globe-widget__country') 1023 .attr('d', path) 1024 .attr('fill', (country) => fillForCountry(country)) 1025 .on('mousemove', (country) => { 1026 const info = countryInfoById[country.id]; 1027 if (primaryPolygonFeatureByCountryId[country.id] && !stateFeatureByCountryId[country.id]) { 1028 clearHoverState(); 1029 return; 1030 } 1031 1032 const isClickable = isClickableCountry(info); 1033 setHoverState(country.id, isClickable ? 'pointer' : 'default'); 1034 }) 1035 .on('mouseleave', clearHoverState) 1036 .on('click', (country) => { 1037 const info = countryInfoById[country.id]; 1038 if (primaryPolygonFeatureByCountryId[country.id] && !stateFeatureByCountryId[country.id]) { 1039 return; 1040 } 1041 if (!isClickableCountry(info)) { 1042 return; 1043 } 1044 const centroid = highlightedStateCentroidByCountryId[country.id] || d3.geoCentroid(country); 1045 focusCountry(country.id, info, centroid); 1046 }); 1047 1048 const statesGroup = svg.append('g').attr('class', 'ziptrak-globe-widget__states'); 1049 statePaths = statesGroup 1050 .selectAll('path') 1051 .data(highlightedStates) 1052 .enter() 1053 .append('path') 1054 .attr('class', 'ziptrak-globe-widget__state') 1055 .attr('d', (datum) => path(datum.feature)) 1056 .attr('fill', (datum) => fillForState(datum)) 1057 .on('mousemove', (datum) => { 1058 setHoverState(datum.country.id, 'pointer'); 1059 }) 1060 .on('mouseleave', clearHoverState) 1061 .on('click', (datum) => { 1062 if (!datum.info) { 1063 return; 1064 } 1065 focusCountry(datum.focusId, datum.info, datum.centroid); 1066 }); 1067 1068 const primaryCountriesGroup = svg.append('g').attr('class', 'ziptrak-globe-widget__primary-countries'); 1069 primaryCountryPaths = primaryCountriesGroup 1070 .selectAll('path') 1071 .data(primaryClickableCountries) 1072 .enter() 1073 .append('path') 1074 .attr('class', 'ziptrak-globe-widget__country ziptrak-globe-widget__country--primary') 1075 .attr('d', (datum) => path(datum.feature)) 1076 .attr('fill', (datum) => fillForPrimaryCountry(datum)) 1077 .on('mousemove', (datum) => { 1078 setHoverState(datum.country.id, 'pointer'); 1079 }) 1080 .on('mouseleave', clearHoverState) 1081 .on('click', (datum) => { 1082 focusCountry(datum.country.id, datum.info, datum.centroid); 1083 }); 1084 1085 const pulseGroup = svg.append('g').attr('class', 'ziptrak-globe-widget__pulses'); 1086 const pulseTargets = []; 1087 highlightedCountries.forEach((country) => { 1088 const info = countryInfoById[country.id]; 1089 const primaryPolygon = primaryPolygonFeatureByCountryId[country.id]; 1090 const centroid = highlightedStateCentroidByCountryId[country.id] 1091 || (primaryPolygon ? d3.geoCentroid(primaryPolygon) : d3.geoCentroid(country)); 1092 if (!info || !centroid || !isFinite(centroid[0]) || !isFinite(centroid[1])) { 1093 return; 1094 } 1095 1096 pulseTargets.push({ 1097 focusId: country.id, 1098 info, 1099 centroid 1100 }); 1101 }); 1102
1103 highlightedVirtualCountries.forEach((info) => { 1104 const centroid = Array.isArray(info.centroid) ? info.centroid : null; 1105 if (!centroid || !isFinite(centroid[0]) || !isFinite(centroid[1])) { 1106 return; 1107 } 1108 1109 pulseTargets.push({ 1110 focusId: info.focusId, 1111 info, 1112 centroid 1113 }); 1114 }); 1115 1116 const handlePulseClick = (datum) => { 1117 if (!isFrontHemisphere(projection, datum.centroid)) { 1118 return; 1119 } 1120 d3.event.preventDefault(); 1121 d3.event.stopPropagation(); 1122 focusCountry(datum.focusId, datum.info, datum.centroid); 1123 }; 1124 1125 pulseTargets.forEach((target) => { 1126 const point = projection(target.centroid); 1127 if (!point) { 1128 return; 1129 } 1130 1131 pulseGroup.append('circle') 1132 .attr('class', 'ziptrak-globe-widget__pulse-core') 1133 .datum(target) 1134 .attr('cx', point[0]) 1135 .attr('cy', point[1]) 1136 .attr('r', 3) 1137 .attr('fill', '#fff') 1138 .style('cursor', 'pointer') 1139 .on('click', handlePulseClick); 1140 1141 createPulse( 1142 pulseGroup.append('circle') 1143 .attr('class', 'ziptrak-globe-widget__pulse-ring') 1144 .datum(target) 1145 .attr('cx', point[0]) 1146 .attr('cy', point[1]) 1147 .style('cursor', 'pointer') 1148 .on('click', handlePulseClick) 1149 ); 1150 }); 1151 1152 const updateProjection = () => { 1153 svg.select('.ziptrak-globe-widget__sphere').attr('d', path); 1154 countryPaths.attr('d', path); 1155 bordersPath.attr('d', path); 1156 statePaths 1157 .attr('d', (datum) => path(datum.feature)) 1158 .each(function updateState(datum) { 1159 const isFront = isFrontHemisphere(projection, datum.centroid); 1160 d3.select(this) 1161 .attr('display', isFront ? null : 'none') 1162 .style('pointer-events', isFront ? 'auto' : 'none'); 1163 }); 1164 primaryCountryPaths 1165 .attr('d', (datum) => path(datum.feature)) 1166 .each(function updatePrimaryCountry(datum) { 1167 const isFront = isFrontHemisphere(projection, datum.centroid); 1168 d3.select(this) 1169 .attr('display', isFront ? null : 'none') 1170 .style('pointer-events', isFront ? 'auto' : 'none'); 1171 }); 1172 1173 pulseGroup.selectAll('.ziptrak-globe-widget__pulse-core').each(function updateCore(datum) { 1174 const point = projection(datum.centroid); 1175 const isFront = isFrontHemisphere(projection, datum.centroid); 1176 const opacity = isFront ? 1 : backsideDotOpacity; 1177 if (!point) { 1178 d3.select(this).attr('display', 'none'); 1179 return; 1180 } 1181 d3.select(this) 1182 .attr('display', null) 1183 .attr('cx', point[0]) 1184 .attr('cy', point[1]) 1185 .attr('opacity', opacity) 1186 .style('pointer-events', isFront ? 'auto' : 'none') 1187 .style('cursor', isFront ? 'pointer' : 'default'); 1188 }); 1189 1190 pulseGroup.selectAll('.ziptrak-globe-widget__pulse-ring').each(function updateRing(datum) { 1191 const point = projection(datum.centroid); 1192 const isFront = isFrontHemisphere(projection, datum.centroid); 1193 const opacity = isFront ? 1 : backsideDotOpacity; 1194 if (!point) { 1195 d3.select(this).attr('display', 'none'); 1196 return; 1197 } 1198 d3.select(this) 1199 .attr('display', null) 1200 .attr('cx', point[0]) 1201 .attr('cy', point[1]) 1202 .attr('opacity', opacity) 1203 .style('pointer-events', isFront ? 'auto' : 'none') 1204 .style('cursor', isFront ? 'pointer' : 'default'); 1205 }); 1206 1207 if (focusedCountryId !== null && isFocusTooltipVisible) { 1208 updateTooltipLinePosition(); 1209 } 1210 }; 1211 1212 const stopRotation = () => { 1213 if (rotationTimer) { 1214 rotationTimer.stop(); 1215 rotationTimer = null; 1216 } 1217 stopRotationAnimation(); 1218 }; 1219 1220 const animateRotationTo = (targetRotate, duration, onDone) => { 1221 stopRotationAnimation(); 1222 const startRotate = projection.rotate().slice(); 1223 const interpolator = d3.interpolateArray(startRotate, targetRotate); 1224 const startTime = Date.now(); 1225 1226 const tick = () => { 1227 const elapsed = Date.now() - startTime; 1228 const progress = clamp(elapsed / duration, 0, 1); 1229 const eased = d3.easeCubicInOut(progress); 1230 projection.rotate(interpolator(eased)); 1231 updateProjection(); 1232 1233 if (progress < 1) { 1234 rotationAnimationFrame = window.requestAnimationFrame(tick); 1235 return; 1236 } 1237 1238 rotationAnimationFrame = null; 1239 if (typeof onDone === 'function') { 1240 onDone(); 1241 } 1242 }; 1243 1244 rotationAnimationFrame = window.requestAnimationFrame(tick); 1245 }; 1246 1247 const closeFocusCountry = () => { 1248 if (focusedCountryId === null) { 1249 return; 1250 } 1251 1252 focusRequestId += 1; 1253 const targetRotation = preFocusRotation ? preFocusRotation.slice() : projection.rotate().slice(); 1254 focusedCountryId = null; 1255 focusedCentroid = null; 1256 hoveredCountryId = null; 1257 hideFocusTooltip(); 1258 refreshInteractiveFills(); 1259 animateRotationTo(targetRotation, ROTATION_DURATION, () => { 1260 maybeStartRotation(); 1261 }); 1262 }; 1263 1264 const handleViewportScroll = () => { 1265 if (focusedCountryId === null) { 1266 return; 1267 } 1268 1269 if (isScrollCloseSuppressed()) { 1270 if (isFocusTooltipVisible) { 1271 updateTooltipPosition(); 1272 updateTooltipLinePosition(); 1273 } 1274 return; 1275 } 1276 1277 closeFocusCountry(); 1278 }; 1279 1280 const focusCountry = (focusId, info, centroid) => { 1281 if (!centroid || !isFinite(centroid[0]) || !isFinite(centroid[1])) { 1282 return; 1283 } 1284
1285 const requestId = focusRequestId + 1; 1286 focusRequestId = requestId; 1287 stopRotation(); 1288 preFocusRotation = projection.rotate().slice(); 1289 focusedCountryId = focusId; 1290 focusedCentroid = centroid; 1291 hoveredCountryId = null; 1292 hideFocusTooltip(); 1293 refreshInteractiveFills(); 1294 1295 scrollSectionIntoFocus(requestId).then(() => { 1296 if (requestId !== focusRequestId) { 1297 return; 1298 } 1299 1300 const targetRotate = getFocusRotateForCentroid(centroid, config.focusLonOffset, config.focusLatOffset); 1301 animateRotationTo(targetRotate, ROTATION_DURATION, () => { 1302 if (requestId !== focusRequestId) { 1303 return; 1304 } 1305 1306 showFocusTooltip(info); 1307 }); 1308 }); 1309 }; 1310 1311 const startRotation = () => { 1312 stopRotation(); 1313 if (config.rotationSpeed <= 0) { 1314 return; 1315 } 1316 1317 let lastTick = null; 1318 rotationTimer = d3.timer(() => { 1319 const now = Date.now(); 1320 if (lastTick === null) { 1321 lastTick = now; 1322 return; 1323 } 1324 const delta = now - lastTick; 1325 if (delta < ROTATION_MIN_FRAME_INTERVAL) { 1326 return; 1327 } 1328 const rotate = projection.rotate(); 1329 rotate[0] += (config.rotationSpeed * delta) / 40; 1330 if (rotate[0] >= 180) { 1331 rotate[0] -= 360; 1332 } 1333 projection.rotate(rotate); 1334 updateProjection(); 1335 lastTick = now; 1336 }); 1337 }; 1338 1339 const maybeStartRotation = () => { 1340 if (focusedCountryId !== null || !isWidgetVisible) { 1341 return; 1342 } 1343 startRotation(); 1344 }; 1345 1346 let dragUpdateFrame = null; 1347 const scheduleDragUpdate = () => { 1348 if (dragUpdateFrame !== null) { 1349 return; 1350 } 1351 dragUpdateFrame = window.requestAnimationFrame(() => { 1352 dragUpdateFrame = null; 1353 updateProjection(); 1354 }); 1355 }; 1356 1357 svg.call( 1358 d3.drag() 1359 .on('start', () => { 1360 if (focusedCountryId !== null) { 1361 return; 1362 } 1363 stopRotation(); 1364 }) 1365 .on('drag', () => { 1366 if (focusedCountryId !== null) { 1367 return; 1368 } 1369 const rotate = projection.rotate(); 1370 const factor = dragSensitivity / projection.scale(); 1371 projection.rotate([ 1372 rotate[0] + d3.event.dx * factor, 1373 rotate[1] - d3.event.dy * factor 1374 ]); 1375 scheduleDragUpdate(); 1376 }) 1377 .on('end', () => { 1378 if (focusedCountryId !== null) { 1379 return; 1380 } 1381 maybeStartRotation(); 1382 }) 1383 ); 1384 1385 widget.addEventListener('click', (event) => { 1386 if (focusedCountryId === null) { 1387 return; 1388 } 1389 1390 const clickedInsideTooltip = syncedTooltip && syncedTooltip.contains(event.target); 1391 const clickedCountryPath = event.target && event.target.closest && event.target.closest('.ziptrak-globe-widget__country'); 1392 const clickedStatePath = event.target && event.target.closest && event.target.closest('.ziptrak-globe-widget__state'); 1393 if (clickedInsideTooltip || clickedCountryPath || clickedStatePath) { 1394 return; 1395 } 1396 1397 closeFocusCountry(); 1398 }); 1399 1400 let resizeFrame = null; 1401 const resize = () => { 1402 const previousWidth = width; 1403 const previousHeight = height; 1404 applyWidgetSize(); 1405 1406 if (previousWidth !== width || previousHeight !== height) { 1407 svg 1408 .attr('width', width) 1409 .attr('height', height) 1410 .attr('viewBox', `0 0 ${width} ${height}`); 1411 1412 projection 1413 .scale(Math.min(width, height) * 0.47) 1414 .translate([width / 2, height / 2]); 1415 updateProjection(); 1416 } 1417 1418 updateTooltipPosition(); 1419 updateTooltipLinePosition(); 1420 }; 1421 1422 const handleResize = () => { 1423 if (resizeFrame !== null) { 1424 window.cancelAnimationFrame(resizeFrame); 1425 } 1426 1427 resizeFrame = window.requestAnimationFrame(() => { 1428 resizeFrame = null; 1429 resize(); 1430 }); 1431 }; 1432 1433 window.addEventListener('resize', handleResize); 1434 window.addEventListener('scroll', handleViewportScroll, { passive: true }); 1435 window.addEventListener('wheel', handleViewportScroll, { passive: true }); 1436 window.addEventListener('touchmove', handleViewportScroll, { passive: true }); 1437 1438 if (typeof window.IntersectionObserver === 'function') { 1439 const visibilityObserver = new window.IntersectionObserver((entries) => { 1440 const entry = entries[entries.length - 1]; 1441 isWidgetVisible = Boolean(entry && entry.isIntersecting); 1442 if (isWidgetVisible) { 1443 maybeStartRotation(); 1444 } else { 1445 stopRotation(); 1446 } 1447 }, { threshold: 0 }); 1448 visibilityObserver.observe(widget); 1449 } 1450 1451 updateProjection(); 1452 startRotation(); 1453 }; 1454 1455 const initGlobes = () => { 1456 const widgets = document.querySelectorAll('.ziptrak-globe-widget[data-globe-config]'); 1457 if (!widgets.length || typeof window.d3 === 'undefined' || typeof window.topojson === 'undefined') { 1458 return; 1459 } 1460 1461 getWorldData() 1462 .then((payload) => {
1463 widgets.forEach((widget) => { 1464 setupWidget( 1465 widget, 1466 payload.worldData, 1467 payload.admin1Features, 1468 payload.countryMetaById, 1469 payload.fallbackCountryByToken || {}, 1470 payload.primaryPolygonOnlyTokenSet || {} 1471 ); 1472 }); 1473 }); 1474 }; 1475 1476 if (document.readyState === 'loading') { 1477 document.addEventListener('DOMContentLoaded', initGlobes); 1478 } else { 1479 initGlobes(); 1480 } 1481})();
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.